From 0a98a648bb185c48a459b30242afe15e2fc1eb5b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 21:29:11 +0100 Subject: [PATCH 001/927] Bump version to 2024.1.0dev0 (#104746) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71030e50074..b9b9c8babb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2023.12" + HA_SHORT_VERSION: "2024.1" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900..8da1c251b4e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,8 +5,8 @@ from enum import StrEnum from typing import Final APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 12 +MAJOR_VERSION: Final = 2024 +MINOR_VERSION: Final = 1 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 71e58bf2177..844fac7142f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2024.1.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bdf4c61a052a2e2b970b95e759f4c3e3e04737ff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 21:41:32 +0100 Subject: [PATCH 002/927] Add faa_delays to strict typing (#104720) --- .strict-typing | 1 + .../components/faa_delays/binary_sensor.py | 56 +++++++++++-------- .../components/faa_delays/config_flow.py | 6 +- .../components/faa_delays/coordinator.py | 8 +-- mypy.ini | 10 ++++ 5 files changed, 54 insertions(+), 27 deletions(-) diff --git a/.strict-typing b/.strict-typing index 3c18a1988f3..2c8717596c6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -120,6 +120,7 @@ homeassistant.components.energy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.feedreader.* diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5cbb206f223..5a209a41de1 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,8 +1,12 @@ """Platform for FAA Delays sensor component.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any +from faadelays import Airport + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -12,33 +16,47 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FAADataUpdateCoordinator from .const import DOMAIN -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( + +@dataclass(kw_only=True) +class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mixin for required keys.""" + + is_on_fn: Callable[[Airport], bool | None] + + +FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( + FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", name="Ground Delay", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", name="Ground Stop", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_stop.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", name="Departure Delay", icon="mdi:airplane-takeoff", + is_on_fn=lambda airport: airport.depart_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", name="Arrival Delay", icon="mdi:airplane-landing", + is_on_fn=lambda airport: airport.arrive_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="CLOSURE", name="Closure", icon="mdi:airplane:off", + is_on_fn=lambda airport: airport.closure.status, ), ) @@ -57,11 +75,16 @@ async def async_setup_entry( async_add_entities(entities) -class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): +class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + entity_description: FaaDelaysBinarySensorEntityDescription + def __init__( - self, coordinator, entry_id, description: BinarySensorEntityDescription + self, + coordinator: FAADataUpdateCoordinator, + entry_id: str, + description: FaaDelaysBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -75,23 +98,12 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): self._attr_unique_id = f"{_id}_{description.key}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return the status of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - return self.coordinator.data.ground_delay.status - if sensor_type == "GROUND_STOP": - return self.coordinator.data.ground_stop.status - if sensor_type == "DEPART_DELAY": - return self.coordinator.data.depart_delay.status - if sensor_type == "ARRIVE_DELAY": - return self.coordinator.data.arrive_delay.status - if sensor_type == "CLOSURE": - return self.coordinator.data.closure.status - return None + return self.entity_description.is_on_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" sensor_type = self.entity_description.key if sensor_type == "GROUND_DELAY": diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index b2f7f69dd49..2f91ce9f797 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -1,5 +1,6 @@ """Config flow for FAA Delays integration.""" import logging +from typing import Any from aiohttp import ClientConnectionError import faadelays @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ID +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index f2aefdada66..2f110cf7730 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -6,6 +6,7 @@ import logging from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,19 +15,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class FAADataUpdateCoordinator(DataUpdateCoordinator): +class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass, code): + def __init__(self, hass: HomeAssistant, code: str) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) - self.code = code - async def _async_update_data(self): + async def _async_update_data(self) -> Airport: try: async with asyncio.timeout(10): await self.data.update() diff --git a/mypy.ini b/mypy.ini index 0ed06edaa1d..e01be53db3d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -961,6 +961,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.faa_delays.*] +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.fan.*] check_untyped_defs = true disallow_incomplete_defs = true From 7638b9bba1ad57f6d1f4e88e9f09bcec577ad490 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Nov 2023 21:54:05 +0100 Subject: [PATCH 003/927] Fix Philips TV none recordings_list (#104665) Correct for missing recordings list in api client. --------- Co-authored-by: Joakim Plate --- .../components/philips_js/binary_sensor.py | 2 ++ .../philips_js/test_binary_sensor.py | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 78aa9f17b05..1e6c1241aea 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry( def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False for rec in api.recordings_list["recordings"]: if rec.get(entry) == value: return True diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py index d11f3fe22f1..01233706d07 100644 --- a/tests/components/philips_js/test_binary_sensor.py +++ b/tests/components/philips_js/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for philips_js binary_sensor.""" import pytest -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import MOCK_NAME, MOCK_RECORDINGS_LIST @@ -32,7 +32,16 @@ async def mock_tv_api_valid(mock_tv): return mock_tv -async def test_recordings_list_invalid( +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant ) -> None: """Test if sensors are not created if mock_tv is invalid.""" @@ -54,7 +63,21 @@ async def test_recordings_list_valid( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get(ID_RECORDING_AVAILABLE) - assert state.state is STATE_ON + assert state.state == STATE_ON state = hass.states.get(ID_RECORDING_ONGOING) - assert state.state is STATE_ON + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF From 9126b00dfe3fedc5a98d6e61064f08a73a9f6959 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 22:01:03 +0100 Subject: [PATCH 004/927] Move Faa delays state attributes to entity description (#104748) --- .../components/faa_delays/binary_sensor.py | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5a209a41de1..e8d7a03752f 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -25,6 +25,7 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): """Mixin for required keys.""" is_on_fn: Callable[[Airport], bool | None] + extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]] FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( @@ -33,30 +34,54 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( name="Ground Delay", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_delay.status, + extra_state_attributes_fn=lambda airport: { + "average": airport.ground_delay.average, + "reason": airport.ground_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", name="Ground Stop", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_stop.status, + extra_state_attributes_fn=lambda airport: { + "endtime": airport.ground_stop.endtime, + "reason": airport.ground_stop.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", name="Departure Delay", icon="mdi:airplane-takeoff", is_on_fn=lambda airport: airport.depart_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.depart_delay.minimum, + "maximum": airport.depart_delay.maximum, + "trend": airport.depart_delay.trend, + "reason": airport.depart_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", name="Arrival Delay", icon="mdi:airplane-landing", is_on_fn=lambda airport: airport.arrive_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.arrive_delay.minimum, + "maximum": airport.arrive_delay.maximum, + "trend": airport.arrive_delay.trend, + "reason": airport.arrive_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="CLOSURE", name="Closure", icon="mdi:airplane:off", is_on_fn=lambda airport: airport.closure.status, + extra_state_attributes_fn=lambda airport: { + "begin": airport.closure.start, + "end": airport.closure.end, + }, ), ) @@ -89,10 +114,6 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - - self.coordinator = coordinator - self._entry_id = entry_id - self._attrs: dict[str, Any] = {} _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" @@ -105,24 +126,4 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - self._attrs["average"] = self.coordinator.data.ground_delay.average - self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif sensor_type == "GROUND_STOP": - self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime - self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif sensor_type == "DEPART_DELAY": - self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum - self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum - self._attrs["trend"] = self.coordinator.data.depart_delay.trend - self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif sensor_type == "ARRIVE_DELAY": - self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum - self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum - self._attrs["trend"] = self.coordinator.data.arrive_delay.trend - self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.start - self._attrs["end"] = self.coordinator.data.closure.end - return self._attrs + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) From 8e64eff62620bc3744fab55b031ece11706205be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 22:23:46 +0100 Subject: [PATCH 005/927] Add entity translations to faa_delays (#104749) --- .../components/faa_delays/binary_sensor.py | 19 +++-- .../components/faa_delays/strings.json | 71 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index e8d7a03752f..c72fedaf59a 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry 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 @@ -31,7 +32,7 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", - name="Ground Delay", + translation_key="ground_delay", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_delay.status, extra_state_attributes_fn=lambda airport: { @@ -41,7 +42,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", - name="Ground Stop", + translation_key="ground_stop", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_stop.status, extra_state_attributes_fn=lambda airport: { @@ -51,7 +52,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", - name="Departure Delay", + translation_key="depart_delay", icon="mdi:airplane-takeoff", is_on_fn=lambda airport: airport.depart_delay.status, extra_state_attributes_fn=lambda airport: { @@ -63,7 +64,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", - name="Arrival Delay", + translation_key="arrive_delay", icon="mdi:airplane-landing", is_on_fn=lambda airport: airport.arrive_delay.status, extra_state_attributes_fn=lambda airport: { @@ -75,7 +76,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="CLOSURE", - name="Closure", + translation_key="closure", icon="mdi:airplane:off", is_on_fn=lambda airport: airport.closure.status, extra_state_attributes_fn=lambda airport: { @@ -103,6 +104,8 @@ async def async_setup_entry( class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + _attr_has_entity_name = True + entity_description: FaaDelaysBinarySensorEntityDescription def __init__( @@ -117,6 +120,12 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, _id)}, + name=_id, + manufacturer="Federal Aviation Administration", + entry_type=DeviceEntryType.SERVICE, + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json index 92a9dafb4da..145c9e3ab34 100644 --- a/homeassistant/components/faa_delays/strings.json +++ b/homeassistant/components/faa_delays/strings.json @@ -17,5 +17,76 @@ "abort": { "already_configured": "This airport is already configured." } + }, + "entity": { + "binary_sensor": { + "ground_delay": { + "name": "Ground delay", + "state_attributes": { + "average": { + "name": "Average" + }, + "reason": { + "name": "Reason" + } + } + }, + "ground_stop": { + "name": "Ground stop", + "state_attributes": { + "endtime": { + "name": "End time" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "depart_delay": { + "name": "Departure delay", + "state_attributes": { + "minimum": { + "name": "Minimum" + }, + "maximum": { + "name": "Maximum" + }, + "trend": { + "name": "Trend" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "arrive_delay": { + "name": "Arrival delay", + "state_attributes": { + "minimum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]" + }, + "maximum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]" + }, + "trend": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "closure": { + "name": "Closure", + "state_attributes": { + "begin": { + "name": "Begin" + }, + "end": { + "name": "End" + } + } + } + } } } From c8aed064384d335be2ab33da57137515083742b0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 13:37:43 -0800 Subject: [PATCH 006/927] Add due date and description to Google Tasks (#104654) * Add tests for config validation function * Add Google Tasks due date and description * Revert test timezone * Update changes after upstream * Update homeassistant/components/google_tasks/todo.py Co-authored-by: Martin Hjelmare * Add google tasks tests for creating --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google_tasks/todo.py | 39 ++++++--- .../google_tasks/snapshots/test_todo.ambr | 58 ++++++++++--- tests/components/google_tasks/test_todo.py | 85 +++++++++---------- 3 files changed, 114 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d3c4dfa6936..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,7 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -14,6 +14,7 @@ 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 homeassistant.util import dt as dt_util from .api import AsyncConfigEntryAuth from .const import DOMAIN @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + if (description := item.description) is not None: + result["notes"] = description return result +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), # type: ignore[arg-type] - TodoItemStatus.NEEDS_ACTION, - ), - ) - for item in _order_tasks(self.coordinator.data) - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 73289b313d9..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,11 +1,29 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- # name: test_delete_todo_list_item[_handler] @@ -38,6 +56,33 @@ }), ]) # --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -47,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 0b82815b33a..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { @@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = { ], } +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] + @pytest.fixture def platforms() -> list[str]: @@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", }, { "id": "task-2", "title": "Task 2", "status": "completed", "position": "0000000000000002", + "notes": "long description", }, ], }, @@ -238,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -333,21 +350,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=["summary", "due", "description"], ) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -361,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -407,17 +423,7 @@ async def test_create_todo_list_item_error( ) -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -483,21 +489,20 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=("rename", "due_date", "description"), ) -async def test_partial_update_title( +async def test_partial_update( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -511,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -522,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, From 2437fb314f80d75da85750824473cbbfc83a2a0d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 30 Nov 2023 00:05:10 +0200 Subject: [PATCH 007/927] Add strict typing to push bullet (#99538) Co-authored-by: Franck Nijhof --- .strict-typing | 1 + homeassistant/components/pushbullet/api.py | 3 ++- homeassistant/components/pushbullet/notify.py | 9 ++++++--- mypy.ini | 10 ++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2c8717596c6..a4969bcc810 100644 --- a/.strict-typing +++ b/.strict-typing @@ -265,6 +265,7 @@ homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* +homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index ff6a57aa931..691ef7413c3 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,4 +1,5 @@ """Pushbullet Notification provider.""" +from __future__ import annotations from typing import Any @@ -10,7 +11,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import DATA_UPDATED -class PushBulletNotificationProvider(Listener): +class PushBulletNotificationProvider(Listener): # type: ignore[misc] """Provider for an account, leading to one or more sensors.""" def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 1cc851bdb99..662240d0bf5 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .api import PushBulletNotificationProvider from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -34,8 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet - return PushBulletNotificationService(hass, pushbullet) + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ + discovery_info["entry_id"] + ] + return PushBulletNotificationService(hass, pb_provider.pushbullet) class PushBulletNotificationService(BaseNotificationService): @@ -120,7 +123,7 @@ class PushBulletNotificationService(BaseNotificationService): pusher: PushBullet, email: str | None = None, phonenumber: str | None = None, - ): + ) -> None: """Create the message content.""" kwargs = {"body": message, "title": title} if email: diff --git a/mypy.ini b/mypy.ini index e01be53db3d..a27282fc667 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2411,6 +2411,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pushbullet.*] +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.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true From e8091a47db337f06ee10846ac272069566546fe7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 29 Nov 2023 17:13:05 -0500 Subject: [PATCH 008/927] Bump pynws to 1.6.0 (#104679) --- homeassistant/components/nws/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec0e5f232e2..d19df8c1c9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1922,7 +1922,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75947854c7f..5bf8f26c874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1451,7 +1451,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 From ec3795ab974a7f3853dce3c8798965aea8b61aab Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 23:16:58 +0100 Subject: [PATCH 009/927] Revert "Add proj dependency to our wheels builder (#104699)" (#104704) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9d16954cd09..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 04f1b11ef723b468517b68530474e95ff382d8f6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 00:32:12 +0100 Subject: [PATCH 010/927] Debug level logging for DSMR migration code (#104757) --- homeassistant/components/dsmr/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 487f996ac1f..f56e2c3ed33 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -456,12 +456,12 @@ def rename_old_gas_to_mbus( device_id=mbus_device_id, ) except ValueError: - LOGGER.warning( + LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) else: - LOGGER.info( + LOGGER.debug( "Migrated entity %s from unique id %s to %s", entity.entity_id, entity.unique_id, From d7932031632d3f2bb6935a1605403a15d825b5c6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 18:31:27 -0600 Subject: [PATCH 011/927] Skip TTS when text is empty (#104741) Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/pipeline.py | 59 ++++++++------- .../components/esphome/voice_assistant.py | 22 +++--- homeassistant/components/voip/voip.py | 15 ++-- .../snapshots/test_websocket.ambr | 27 +++++++ .../assist_pipeline/test_websocket.py | 51 +++++++++++++ .../esphome/test_voice_assistant.py | 22 ++++++ tests/components/voip/test_voip.py | 72 +++++++++++++++++++ 7 files changed, 225 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1eb32a9dc3f..4f2a9a8d99b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1024,39 +1024,38 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) return tts_media.url diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 68ed98aa789..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 74bc94e7dc5..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -389,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 1f625528806..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -650,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 931b31dd77b..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2452,3 +2452,54 @@ async def test_device_capture_queue_full( assert msg["event"] == snapshot assert msg["event"]["type"] == "end" assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 692896c6dfa..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -528,3 +528,75 @@ async def test_tts_wrong_wav_format( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() From b957c4e3ee4f022742fdf7066741fdd0137bebf8 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:31:39 +0100 Subject: [PATCH 012/927] Bump zamg to 0.3.3 (#104756) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d19df8c1c9a..de84d8fb420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2807,7 +2807,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf8f26c874..f2241b1c5d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2105,7 +2105,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf zeroconf==0.127.0 From ec647677e98a8b627481c8b6534c51c193203368 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:38:33 +0100 Subject: [PATCH 013/927] Update initial translation for ViCare water heater entity (#104696) --- homeassistant/components/vicare/strings.json | 4 ++-- homeassistant/components/vicare/water_heater.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index e9ee272edd8..47ee60b2ea8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -283,8 +283,8 @@ } }, "water_heater": { - "water": { - "name": "Water" + "domestic_hot_water": { + "name": "Domestic hot water" } } }, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 036ced5ee55..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -64,13 +64,13 @@ def _build_entities( api: PyViCareDevice, device_config: PyViCareDeviceConfig, ) -> list[ViCareWater]: - """Create ViCare water entities for a device.""" + """Create ViCare domestic hot water entities for a device.""" return [ ViCareWater( api, circuit, device_config, - "water", + "domestic_hot_water", ) for circuit in get_circuits(api) ] @@ -81,7 +81,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] From 9fa163c1076285b97988cb924110bf1b6ea1e842 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 01:50:37 +0100 Subject: [PATCH 014/927] Reolink cleanup when CAM disconnected from NVR (#103888) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/__init__.py | 56 +++++++++++++++++ tests/components/reolink/conftest.py | 6 +- .../reolink/snapshots/test_diagnostics.ambr | 2 +- tests/components/reolink/test_init.py | 61 ++++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 46761beae00..eca5a5aa853 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -148,6 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +178,56 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels or channels where a different model camera is connected.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean entity and device registry + entity_reg = er.async_get(hass) + entities = er.async_entries_for_device( + entity_reg, device.id, include_disabled_entities=True + ) + for entity in entities: + entity_reg.async_remove(entity.entity_id) + + device_reg.async_remove_device(device.id) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 75d2dc0c661..464d4120c65 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -25,6 +25,8 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -70,8 +72,8 @@ def reolink_connect_class( host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" - host_mock.model = "RLC-123" - host_mock.camera_model.return_value = "RLC-123" + host_mock.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 604a9364320..9f70673695c 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'event connection': 'Fast polling', 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', - 'model': 'RLC-123', + 'model': 'RLN8-410', 'stream channels': list([ 0, ]), diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e2bd622bb43..6a9a8b957db 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,58 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) + + # reload integration after 'disconnecting' a camera. + if attr is not None: + setattr(reolink_connect, attr, value) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_reload(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: From c72e4e8b5cd6c5872d6190a4f8040429b31c2804 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 21:11:38 -0800 Subject: [PATCH 015/927] Fix bug in rainbird device ids that are int serial numbers (#104768) --- homeassistant/components/rainbird/__init__.py | 2 +- tests/components/rainbird/test_init.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index c149c993acb..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -183,7 +183,7 @@ def _async_fix_device_id( device_entry_map = {} migrations = {} for device_entry in device_entries: - unique_id = next(iter(device_entry.identifiers))[1] + unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7048e1d63f4..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -239,6 +239,14 @@ async def test_fix_unique_id_duplicate( f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", f"{MAC_ADDRESS_UNIQUE_ID}-1", ), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", @@ -268,6 +276,7 @@ async def test_fix_unique_id_duplicate( ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", From 64a6a6a7786f15583135d7f3b88ab4734d642883 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 22:01:57 -0800 Subject: [PATCH 016/927] Add due date and description fields to Todoist To-do entity (#104655) * Add Todoist Due date and description fields * Update entity features with new names * Make items into walrus * Update due_datetime field * Add additional tests for adding new fields to items * Fix call args in todoist test --- homeassistant/components/todoist/todo.py | 41 ++++- tests/components/todoist/conftest.py | 5 +- tests/components/todoist/test_todo.py | 211 +++++++++++++++++++++-- 3 files changed, 236 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..64e83b8cc6e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..4e4d41b6914 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,7 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,8 +56,8 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index fb6f707be47..aa00e2c2ff4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -19,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -57,11 +65,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -71,28 +159,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -158,12 +253,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -174,22 +348,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( From 2f0846bd878ef5f53841ac45bb476632a58e8b5a Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:12:39 +0100 Subject: [PATCH 017/927] Axis: add host and user name field description (#104693) --- homeassistant/components/axis/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, From 6ac9a1d1f276a76ce1293e0dd4c4657190a30fd8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Nov 2023 08:22:16 +0100 Subject: [PATCH 018/927] Migrate GDACS to has entity name (#104733) --- homeassistant/components/gdacs/sensor.py | 10 +++++++++- tests/components/gdacs/test_sensor.py | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 5d5589c54d6..8a0a0113ced 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,6 +7,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity): _attr_should_poll = False _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" + assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id - self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -60,6 +63,11 @@ class GdacsSensor(SensorEntity): self._updated = None self._removed = None self._remove_signal_status: Callable[[], None] | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="GDACS", + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index da318b1a94d..670d3efce51 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -72,10 +72,10 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") assert state is not None assert int(state.state) == 3 - assert state.name == "GDACS (32.87336, -117.22743)" + assert state.name == "32.87336, -117.22743" attributes = state.attributes assert attributes[ATTR_STATUS] == "OK" assert attributes[ATTR_CREATED] == 3 @@ -96,7 +96,7 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_CREATED] == 1 assert attributes[ATTR_UPDATED] == 2 @@ -125,6 +125,6 @@ async def test_setup(hass: HomeAssistant) -> None: == 1 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_REMOVED] == 3 From 8bc1f9d03d401f8ac210ff95b2ef14e68b193466 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:17 +0100 Subject: [PATCH 019/927] Comelit, Coolmaster: add host field description (#104771) --- homeassistant/components/comelit/strings.json | 3 +++ homeassistant/components/coolmaster/strings.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, From 56350d1c0a756f05ce633bff45118f6c29e2e9d1 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:37 +0100 Subject: [PATCH 020/927] Broadlink, BSB-Lan: add host field description (#104770) --- homeassistant/components/broadlink/strings.json | 5 ++++- homeassistant/components/bsblan/strings.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, From da93daaa7542cbfb9800433575f0b870c27f81d4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:43:42 +0100 Subject: [PATCH 021/927] Dremel to Duotecno: add host field description (#104776) Co-authored-by: Franck Nijhof --- homeassistant/components/dremel_3d_printer/strings.json | 3 +++ homeassistant/components/dunehd/strings.json | 3 +++ homeassistant/components/duotecno/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, From dd00357e9cf1d50ba29ea9000824b5b45dc3ca1b Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:44:18 +0100 Subject: [PATCH 022/927] Ecoforest to Emonitor: add host field description (#104778) --- homeassistant/components/ecoforest/strings.json | 3 +++ homeassistant/components/elgato/strings.json | 3 +++ homeassistant/components/emonitor/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cda..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { From 69af4c8603c188a860dfb8d993eaa9c1d8657714 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 11:26:33 +0100 Subject: [PATCH 023/927] Add common reolink entity description (#104142) Co-authored-by: Robert Resch --- .../components/reolink/binary_sensor.py | 14 +++---- homeassistant/components/reolink/button.py | 24 ++++++------ homeassistant/components/reolink/camera.py | 11 ++---- homeassistant/components/reolink/entity.py | 30 ++++++++++++++- homeassistant/components/reolink/light.py | 22 +++++------ homeassistant/components/reolink/number.py | 13 +++---- homeassistant/components/reolink/select.py | 14 +++---- homeassistant/components/reolink/sensor.py | 37 ++++++++----------- homeassistant/components/reolink/siren.py | 16 +++----- homeassistant/components/reolink/switch.py | 27 ++++++++------ 10 files changed, 109 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e2e8e6b24f9..226b81b1c74 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,16 +25,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): +class ReolinkBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes binary sensor entities.""" icon_off: str = "mdi:motion-sensor-off" icon: str = "mdi:motion-sensor" - supported: Callable[[Host, int], bool] = lambda host, ch: True value: Callable[[Host, int], bool] @@ -128,8 +130,8 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt entity_description: ReolinkBinarySensorEntityDescription, ) -> None: """Initialize Reolink binary sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: if entity_description.translation_key is not None: @@ -138,10 +140,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt key = entity_description.key self._attr_translation_key = f"{key}_lens_{self._channel}" - self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" - ) - @property def icon(self) -> str | None: """Icon of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6e9c9c2e386..88204d9a806 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -27,7 +27,12 @@ from homeassistant.helpers.entity_platform import ( from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @@ -36,21 +41,23 @@ SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @dataclass(kw_only=True) class ReolinkButtonEntityDescription( ButtonEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes button entities for a camera channel.""" enabled_default: Callable[[Host, int], bool] | None = None method: Callable[[Host, int], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True ptz_cmd: str | None = None @dataclass(kw_only=True) -class ReolinkHostButtonEntityDescription(ButtonEntityDescription): +class ReolinkHostButtonEntityDescription( + ButtonEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes button entities for the host.""" method: Callable[[Host], Any] - supported: Callable[[Host], bool] = lambda api: True BUTTON_ENTITIES = ( @@ -195,12 +202,9 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description: ReolinkButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) if entity_description.enabled_default is not None: self._attr_entity_registry_enabled_default = ( entity_description.enabled_default(self._host.api, self._channel) @@ -241,10 +245,8 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): entity_description: ReolinkHostButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index ea9b84cd53f..2ad8105c66c 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,10 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @@ -28,11 +27,11 @@ _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) class ReolinkCameraEntityDescription( CameraEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes camera entities for a camera channel.""" stream: str - supported: Callable[[Host, int], bool] = lambda api, ch: True CAMERA_ENTITIES = ( @@ -135,10 +134,6 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): f"{entity_description.translation_key}_lens_{self._channel}" ) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - async def stream_source(self) -> str | None: """Return the source of the stream.""" return await self._host.api.get_stream_source( diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5c874fb7ff9..584b380f391 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,14 @@ """Reolink parent entity class.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,8 +20,22 @@ from .const import DOMAIN _T = TypeVar("_T") +@dataclass(kw_only=True) +class ReolinkChannelEntityDescription(EntityDescription): + """A class that describes entities for a camera channel.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass(kw_only=True) +class ReolinkHostEntityDescription(EntityDescription): + """A class that describes host entities.""" + + supported: Callable[[Host], bool] = lambda api: True + + class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class fo Reolink entities.""" + """Parent class for Reolink entities.""" _attr_has_entity_name = True @@ -59,14 +76,20 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): basically a NVR with a single channel that has the camera connected to that channel. """ + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + def __init__(self, reolink_data: ReolinkData) -> None: """Initialize ReolinkHostCoordinatorEntity.""" super().__init__(reolink_data, reolink_data.device_coordinator) + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + entity_description: ReolinkChannelEntityDescription + def __init__( self, reolink_data: ReolinkData, @@ -76,6 +99,9 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data) self._channel = channel + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index f1aa0cb9ee2..b2d0402b1b9 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -22,17 +22,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkLightEntityDescription(LightEntityDescription): +class ReolinkLightEntityDescription( + LightEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes light entities.""" get_brightness_fn: Callable[[Host, int], int | None] | None = None is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True turn_on_off_fn: Callable[[Host, int, bool], Any] @@ -41,7 +43,7 @@ LIGHT_ENTITIES = ( key="floodlight", translation_key="floodlight", icon="mdi:spotlight-beam", - supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), @@ -52,7 +54,7 @@ LIGHT_ENTITIES = ( translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), + supported=lambda api, ch: api.supported(ch, "ir_lights"), is_on_fn=lambda api, ch: api.ir_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), ), @@ -61,7 +63,7 @@ LIGHT_ENTITIES = ( translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "power_led"), + supported=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), @@ -80,7 +82,7 @@ async def async_setup_entry( ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels - if entity_description.supported_fn(reolink_data.host.api, channel) + if entity_description.supported(reolink_data.host.api, channel) ) @@ -96,12 +98,8 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): entity_description: ReolinkLightEntityDescription, ) -> None: """Initialize Reolink light entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) if entity_description.set_brightness_fn is None: self._attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 1780465850a..6a89eabba2b 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -21,18 +21,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkNumberEntityDescription(NumberEntityDescription): +class ReolinkNumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes number entities.""" get_max_value: Callable[[Host, int], float] | None = None get_min_value: Callable[[Host, int], float] | None = None method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], float | None] @@ -378,8 +380,8 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): entity_description: ReolinkNumberEntityDescription, ) -> None: """Initialize Reolink number entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if entity_description.get_min_value is not None: self._attr_native_min_value = entity_description.get_min_value( @@ -390,9 +392,6 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self._host.api, channel ) self._attr_mode = entity_description.mode - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) @property def native_value(self) -> float | None: diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 566dbc92fbe..3d75b08b5d1 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -24,18 +24,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) -class ReolinkSelectEntityDescription(SelectEntityDescription): +class ReolinkSelectEntityDescription( + SelectEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes select entities.""" get_options: list[str] | Callable[[Host, int], list[str]] method: Callable[[Host, int, str], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None @@ -131,14 +133,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): entity_description: ReolinkSelectEntityDescription, ) -> None: """Initialize Reolink select entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) self._log_error = True - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - if callable(entity_description.get_options): self._attr_options = entity_description.get_options(self._host.api, channel) else: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 9a03f497944..3a5da97dc61 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,31 +21,32 @@ from homeassistant.helpers.typing import StateType from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSensorEntityDescription(SensorEntityDescription): +class ReolinkSensorEntityDescription( + SensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes sensor entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], int] -@dataclass -class ReolinkHostSensorEntityDescriptionMixin: - """Mixin values for Reolink host sensor entities.""" - - value: Callable[[Host], int | None] - - -@dataclass +@dataclass(kw_only=True) class ReolinkHostSensorEntityDescription( - SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], int | None] SENSORS = ( @@ -110,12 +111,8 @@ class ReolinkSensorEntity(ReolinkChannelCoordinatorEntity, SensorEntity): entity_description: ReolinkSensorEntityDescription, ) -> None: """Initialize Reolink sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def native_value(self) -> StateType | date | datetime | Decimal: @@ -134,10 +131,8 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): entity_description: ReolinkHostSensorEntityDescription, ) -> None: """Initialize Reolink host sensor.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) @property def native_value(self) -> StateType | date | datetime | Decimal: diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f063b65e2b4..ec709f6ae3d 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,11 +1,9 @@ """Component providing support for Reolink siren entities.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( @@ -22,15 +20,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass -class ReolinkSirenEntityDescription(SirenEntityDescription): +class ReolinkSirenEntityDescription( + SirenEntityDescription, ReolinkChannelEntityDescription +): """A class that describes siren entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - SIREN_ENTITIES = ( ReolinkSirenEntityDescription( @@ -76,12 +74,8 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): entity_description: ReolinkSirenEntityDescription, ) -> None: """Initialize Reolink siren entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index eb77b16478f..fbb8922188d 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -17,24 +17,33 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSwitchEntityDescription(SwitchEntityDescription): +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes switch entities.""" method: Callable[[Host, int, bool], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], bool] @dataclass(kw_only=True) -class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes NVR switch entities.""" method: Callable[[Host, bool], Any] - supported: Callable[[Host], bool] = lambda api: True value: Callable[[Host], bool] @@ -235,12 +244,8 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): entity_description: ReolinkSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def is_on(self) -> bool: @@ -275,8 +280,8 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): entity_description: ReolinkNVRSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data) self.entity_description = entity_description + super().__init__(reolink_data) self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" From d1a2192e37274e04b3e778eea1a435e6b10261fc Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:59:20 +0100 Subject: [PATCH 024/927] FiveM to Foscam: add host field description (#104782) --- homeassistant/components/fivem/strings.json | 3 +++ homeassistant/components/flo/strings.json | 3 +++ homeassistant/components/foscam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, From 1ab9357840c799375badb1114051bdbb677f4d96 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 13:14:46 +0200 Subject: [PATCH 025/927] Fix wirelesstag unique_id to use uuid instead of tag_id (#104394) Co-authored-by: Robert Resch --- .../components/wirelesstag/__init__.py | 16 ++++++++++ .../components/wirelesstag/binary_sensor.py | 12 ++++--- .../components/wirelesstag/sensor.py | 18 +++++++---- .../components/wirelesstag/switch.py | 32 +++++++++++-------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..f95337dbaf4 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,20 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" From 68e883dc6347ac147671bc54641183aa3f8f1d47 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:50:58 +0100 Subject: [PATCH 026/927] Fix runtime error in CalDAV (#104800) --- homeassistant/components/caldav/api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f9236049048..fa89d6acc38 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) components_results = await asyncio.gather( *[ hass.async_add_executor_job(calendar.get_supported_components) From fa7a74c61157186dfb46c515014aa3a6f2fe1b77 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 13:54:37 +0100 Subject: [PATCH 027/927] Cleanup Reolink formatting (late review) (#104793) cleanup --- homeassistant/components/reolink/__init__.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index eca5a5aa853..7f8448d277d 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -183,7 +183,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> def cleanup_disconnected_cams( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: - """Clean-up disconnected camera channels or channels where a different model camera is connected.""" + """Clean-up disconnected camera channels.""" if not host.api.is_nvr: return @@ -206,14 +206,16 @@ def cleanup_disconnected_cams( if ch not in host.api.channels: remove = True _LOGGER.debug( - "Removing Reolink device %s, since no camera is connected to NVR channel %s anymore", + "Removing Reolink device %s, " + "since no camera is connected to NVR channel %s anymore", device.name, ch, ) if ch_model not in [device.model, "Unknown"]: remove = True _LOGGER.debug( - "Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s", + "Removing Reolink device %s, " + "since the camera model connected to channel %s changed from %s to %s", device.name, ch, device.model, @@ -222,12 +224,5 @@ def cleanup_disconnected_cams( if not remove: continue - # clean entity and device registry - entity_reg = er.async_get(hass) - entities = er.async_entries_for_device( - entity_reg, device.id, include_disabled_entities=True - ) - for entity in entities: - entity_reg.async_remove(entity.entity_id) - + # clean device registry and associated entities device_reg.async_remove_device(device.id) From aa4382e09175e527fe97156963cc75bb4859a948 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 30 Nov 2023 08:22:17 -0500 Subject: [PATCH 028/927] Use .get for Fully Kiosk SSL settings in coordinator (#104801) --- homeassistant/components/fully_kiosk/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 17facb79dbb..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -19,13 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], - use_ssl=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -33,7 +34,6 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) - self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" From f59588b4139f24be149d03017c2a6611dad00abc Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 30 Nov 2023 15:41:58 +0100 Subject: [PATCH 029/927] Make the minimum number of samples used by the trend sensor configurable (#101102) * Make the minimum of samples configurable & raise issue when min_samples > max_samples * Wording * Remove issue creation and use a custom schema validator * Remove issue from strings.json * Add test for validator and fix error message --- .../components/trend/binary_sensor.py | 45 +++++++--- homeassistant/components/trend/const.py | 1 + tests/components/trend/test_binary_sensor.py | 87 +++++++++++++++++-- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2d00f35202c..fa6ad8e5382 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -52,23 +52,39 @@ from .const import ( CONF_INVERT, CONF_MAX_SAMPLES, CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, - vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, - vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, - } + +def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: + if ( + CONF_MIN_SAMPLES in data + and CONF_MAX_SAMPLES in data + and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES] + ): + raise vol.Invalid("min_samples must be smaller than or equal to max_samples") + return data + + +SENSOR_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, + vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + } + ), + _validate_min_max, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -96,6 +112,7 @@ async def async_setup_platform( max_samples = device_config[CONF_MAX_SAMPLES] min_gradient = device_config[CONF_MIN_GRADIENT] sample_duration = device_config[CONF_SAMPLE_DURATION] + min_samples = device_config[CONF_MIN_SAMPLES] sensors.append( SensorTrend( @@ -109,8 +126,10 @@ async def async_setup_platform( max_samples, min_gradient, sample_duration, + min_samples, ) ) + if not sensors: _LOGGER.error("No sensors added") return @@ -137,6 +156,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): max_samples: int, min_gradient: float, sample_duration: int, + min_samples: int, ) -> None: """Initialize the sensor.""" self._hass = hass @@ -148,6 +168,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient + self._min_samples = min_samples self.samples: deque = deque(maxlen=max_samples) @property @@ -210,7 +231,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): while self.samples and self.samples[0][0] < cutoff: self.samples.popleft() - if len(self.samples) < 2: + if len(self.samples) < self._min_samples: return # Calculate gradient of linear trend diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 6787dc08445..3d82bfcc648 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -12,3 +12,4 @@ CONF_INVERT = "invert" CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" +CONF_MIN_SAMPLES = "min_samples" diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index cccf1add61b..ddd980ae970 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,5 +1,6 @@ """The test for the Trend sensor platform.""" from datetime import timedelta +import logging from unittest.mock import patch import pytest @@ -68,6 +69,7 @@ class TestTrendBinarySensor: "sample_duration": 10000, "min_gradient": 1, "max_samples": 25, + "min_samples": 5, } }, } @@ -76,24 +78,35 @@ class TestTrendBinarySensor: self.hass.block_till_done() now = dt_util.utcnow() + + # add not enough states to trigger calculation for val in [10, 0, 20, 30]: with patch("homeassistant.util.dt.utcnow", return_value=now): self.hass.states.set("sensor.test_state", val) self.hass.block_till_done() now += timedelta(seconds=2) - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" + assert ( + self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown" + ) - # have to change state value, otherwise sample will lost + # add one more state to trigger gradient calculation + for val in [100]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + self.hass.states.set("sensor.test_state", val) + self.hass.block_till_done() + now += timedelta(seconds=2) + + assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + # add more states to trigger a downtrend for val in [0, 30, 1, 0]: with patch("homeassistant.util.dt.utcnow", return_value=now): self.hass.states.set("sensor.test_state", val) self.hass.block_till_done() now += timedelta(seconds=2) - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" + assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off" def test_down_using_trendline(self): """Test down trend using multiple samples and trendline calculation.""" @@ -434,10 +447,72 @@ async def test_restore_state( { "binary_sensor": { "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + "sensors": { + "test_trend_sensor": { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + } + }, } }, ) await hass.async_block_till_done() + # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + now = dt_util.utcnow() + + # add not enough samples to trigger calculation + for val in [10, 20, 30, 40]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + now += timedelta(seconds=2) + + # state should match restored state as no calculation happened + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add more samples to trigger calculation + for val in [50, 60, 70, 80]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + now += timedelta(seconds=2) + + # sensor should detect an upwards trend and turn on + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_min_sample( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if error is logged when min_sample is larger than max_samples.""" + with caplog.at_level(logging.ERROR): + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": { + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, + } + }, + } + }, + ) + await hass.async_block_till_done() + + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples" + in record.message + ) From 1c78848c432f7a5d1c5ee3390519879d15eec753 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:38:40 +0100 Subject: [PATCH 030/927] HLK to Hyperion: add host field description (#104789) Co-authored-by: Franck Nijhof --- homeassistant/components/hlk_sw16/strings.json | 3 +++ homeassistant/components/hue/strings.json | 6 ++++++ homeassistant/components/hyperion/strings.json | 3 +++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..122cb489d26 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "link": { diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { From 25ebbda3a9bb8e54caefe7d645267c91bee93da3 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 30 Nov 2023 16:50:13 +0100 Subject: [PATCH 031/927] Add Flexit bacnet integration (#104275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joost Lekkerkerker Co-authored-by: Piotr Buliński Co-authored-by: Piotr Buliński --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/flexit.json | 5 + .../components/flexit_bacnet/__init__.py | 43 +++++ .../components/flexit_bacnet/climate.py | 148 ++++++++++++++++++ .../components/flexit_bacnet/config_flow.py | 62 ++++++++ .../components/flexit_bacnet/const.py | 30 ++++ .../components/flexit_bacnet/manifest.json | 10 ++ .../components/flexit_bacnet/strings.json | 19 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/flexit_bacnet/__init__.py | 1 + tests/components/flexit_bacnet/conftest.py | 44 ++++++ .../flexit_bacnet/test_config_flow.py | 120 ++++++++++++++ 18 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/flexit.json create mode 100644 homeassistant/components/flexit_bacnet/__init__.py create mode 100644 homeassistant/components/flexit_bacnet/climate.py create mode 100644 homeassistant/components/flexit_bacnet/config_flow.py create mode 100644 homeassistant/components/flexit_bacnet/const.py create mode 100644 homeassistant/components/flexit_bacnet/manifest.json create mode 100644 homeassistant/components/flexit_bacnet/strings.json create mode 100644 tests/components/flexit_bacnet/__init__.py create mode 100644 tests/components/flexit_bacnet/conftest.py create mode 100644 tests/components/flexit_bacnet/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f15d36918ec..29b48e439ea 100644 --- a/.coveragerc +++ b/.coveragerc @@ -404,6 +404,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py diff --git a/.strict-typing b/.strict-typing index a4969bcc810..daa4a56dead 100644 --- a/.strict-typing +++ b/.strict-typing @@ -128,6 +128,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..d41975259b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -395,6 +395,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico diff --git a/homeassistant/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000..4c61c5eeb07 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..c9a0b332d93 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + 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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000..28f4a6ae178 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000..2c87dfc5b97 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + VERSION = 1 + + 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: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + 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.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000..269a88c4cec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000..d230e4ebb7a --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json new file mode 100644 index 00000000000..fd2725c6403 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..30c884249a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -149,6 +149,7 @@ FLOWS = { "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..394b40ac630 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1766,9 +1766,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", diff --git a/mypy.ini b/mypy.ini index a27282fc667..05525d03300 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1041,6 +1041,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +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.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index de84d8fb420..7f1b3d338f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,6 +822,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2241b1c5d4..5f379b9a355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -651,6 +651,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..4cae6e4f4bf --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000..b136b134e01 --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000..ed513587af6 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 3bbed8965ab6cb70d1ceebda107df573d4ddf1c6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:55:53 +0100 Subject: [PATCH 032/927] Frontier to Glances: add host field description (#104787) Co-authored-by: Franck Nijhof --- homeassistant/components/frontier_silicon/strings.json | 5 ++++- homeassistant/components/fully_kiosk/strings.json | 3 +++ homeassistant/components/glances/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index bf46feeec3f..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -13,6 +13,9 @@ "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { From cc2c7c7be1dfa794cf1eebaa9872501790f9a317 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:57:44 +0100 Subject: [PATCH 033/927] KMtronic to LG Soundbar: add host field description (#104792) --- homeassistant/components/kmtronic/strings.json | 3 +++ homeassistant/components/kodi/strings.json | 3 +++ homeassistant/components/lg_soundbar/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, From 52450291cb512f0db138316d0a1704bda2b1c91c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 16:59:26 +0100 Subject: [PATCH 034/927] Fix device sync to Google Assistant if Matter integration is active (#104796) * Only get Matter device info if device is an actual Matter device * Return None if matter device does not exist * lint * fix test * adjust google assistant test --- homeassistant/components/google_assistant/helpers.py | 8 ++++++-- homeassistant/components/matter/helpers.py | 2 +- tests/components/google_assistant/test_helpers.py | 1 + tests/components/matter/test_helpers.py | 11 ++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index af892f15af4..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -686,8 +686,12 @@ class GoogleEntity: return device # Add Matter info - if "matter" in self.hass.config.components and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) ): device["matterUniqueId"] = matter_info["unique_id"] device["matterOriginalVendorId"] = matter_info["vendor_id"] diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index dcd6a30ee1f..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 771df137278..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -89,6 +89,7 @@ async def test_google_entity_sync_serialize_with_matter( manufacturer="Someone", model="Some model", sw_version="Some Version", + identifiers={("matter", "12345678")}, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity = entity_registry.async_get_or_create( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index c7a0ed0d8a3..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -60,16 +60,13 @@ async def test_get_node_from_device_entry( assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) From b9ab28150ea1722a0baa4a12c0f89ad85baec1a2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:03:27 +0100 Subject: [PATCH 035/927] iAlarm to Keenetic: add host field description (#104791) Co-authored-by: Andrey Kupreychik --- homeassistant/components/ialarm/strings.json | 3 +++ homeassistant/components/iotawatt/strings.json | 3 +++ homeassistant/components/keenetic_ndms2/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, From 41fb8d50ccb81cc3de21f4467a799b9a4d3c7dc9 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:04:15 +0100 Subject: [PATCH 036/927] Goalzero to HEOS: add host field description (#104786) --- homeassistant/components/goalzero/strings.json | 3 +++ homeassistant/components/harmony/strings.json | 3 +++ homeassistant/components/heos/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index c9c7a559758..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, From 419dc8adb1026c57a3fe5f004bcd2c6d76710f9f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:05:32 +0100 Subject: [PATCH 037/927] Freebox to FRITZ!Box add host field description (#104784) Co-authored-by: Simone Chemelli --- homeassistant/components/freebox/strings.json | 3 +++ homeassistant/components/fritz/strings.json | 3 +++ homeassistant/components/fritzbox/strings.json | 3 +++ homeassistant/components/fritzbox_callmonitor/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { From 7ec651022105c8d94c1f811b778f69b246299d60 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:09:06 +0100 Subject: [PATCH 038/927] Add significant Change support for remote (#104627) --- .../components/remote/significant_change.py | 27 ++++++++ .../remote/test_significant_change.py | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 homeassistant/components/remote/significant_change.py create mode 100644 tests/components/remote/test_significant_change.py diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py new file mode 100644 index 00000000000..8e5a3669041 --- /dev/null +++ b/homeassistant/components/remote/significant_change.py @@ -0,0 +1,27 @@ +"""Helper to test significant Remote state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CURRENT_ACTIVITY + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_ACTIVITY) != new_attrs.get(ATTR_CURRENT_ACTIVITY): + return True + + return False diff --git a/tests/components/remote/test_significant_change.py b/tests/components/remote/test_significant_change.py new file mode 100644 index 00000000000..dcbfce213d6 --- /dev/null +++ b/tests/components/remote/test_significant_change.py @@ -0,0 +1,62 @@ +"""Test the Remote significant change platform.""" +from homeassistant.components.remote import ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY +from homeassistant.components.remote.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Remote significant changes.""" + # no change at all + attrs = { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + } + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + # change of state is significant + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + # change of current activity is significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "paused", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + } + assert async_check_significant_change(None, "on", attrs["old"], "on", attrs["new"]) + + # change of list of possible activities is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing"], + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) + + # change of any not official attribute is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + "not_official": "changed", + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) From b31d805de1c1f0afa1233eba0467079693f6d1a9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 17:09:53 +0100 Subject: [PATCH 039/927] Bump python-matter-server to version 5.0.0 (#104805) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f1b3d338f4..33b4778951d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2172,7 +2172,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f379b9a355..3c774e2e538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1626,7 +1626,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b3d64c39499383dd98e67c1232072407269c5e44 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 30 Nov 2023 17:11:28 +0100 Subject: [PATCH 040/927] Fix Fastdotcom no entity (#104785) Co-authored-by: G Johansson --- homeassistant/components/fastdotcom/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 33ad4853404..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -38,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" From 0c08081386e7fa8e5f8769d4d3c8de368055e4ee Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:24:21 +0100 Subject: [PATCH 041/927] Deconz to DoorBird: add host field description (#104772) * Deconz to DoorBird: add host field description * Update homeassistant/components/deconz/strings.json Co-authored-by: Robert Svensson --------- Co-authored-by: Robert Svensson --- homeassistant/components/deconz/strings.json | 5 ++++- homeassistant/components/deluge/strings.json | 3 +++ homeassistant/components/directv/strings.json | 3 +++ homeassistant/components/dlink/strings.json | 1 + homeassistant/components/doorbird/strings.json | 5 ++++- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, From 3133585d5209b7077efa8e25b48c4b4e0271acb6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:25:04 +0100 Subject: [PATCH 042/927] Enphase to Evil: add host field description (#104779) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/strings.json | 3 +++ homeassistant/components/epson/strings.json | 3 +++ homeassistant/components/evil_genius_labs/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, From b7bf1e9f3f5e04c9911951d19919f66b50061542 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 17:26:07 +0100 Subject: [PATCH 043/927] Make Shelly Wall Display thermostat implementation compatible with firmware 1.2.5 (#104812) --- homeassistant/components/shelly/climate.py | 11 ++++------- homeassistant/components/shelly/switch.py | 5 +++-- homeassistant/components/shelly/utils.py | 7 ------- tests/components/shelly/conftest.py | 3 +-- tests/components/shelly/test_switch.py | 11 +++++++---- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d855e8b238b..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,12 +42,7 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import ( - async_remove_shelly_entity, - get_device_entry_gen, - get_rpc_key_ids, - is_relay_used_as_actuator, -) +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -131,7 +126,9 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 35429c858f5..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -118,8 +118,9 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if coordinator.device.shelly["relay_operational"]: - # Wall Display in relay mode, we need to remove a climate entity + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0209dc63aa8..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -430,10 +430,3 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL - - -def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: - """Return True if an internal relay is used as the thermostat actuator.""" - return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( - "actuator", "" - ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 1662405dc80..6eb74e26dcb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -153,7 +153,6 @@ MOCK_CONFIG = { "id": 0, "enable": True, "type": "heating", - "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", }, "sys": { "ui_data": {}, @@ -181,7 +180,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", - "relay_operational": False, + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 69e1423f75a..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -283,7 +283,8 @@ async def test_block_device_gas_valve( async def test_wall_display_thermostat_mode( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, + mock_rpc_device, ) -> None: """Test Wall Display in thermostat mode.""" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -294,7 +295,10 @@ async def test_wall_display_thermostat_mode( async def test_wall_display_relay_mode( - hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, ) -> None: """Test Wall Display in thermostat mode.""" entity_id = register_entity( @@ -305,8 +309,7 @@ async def test_wall_display_relay_mode( ) new_shelly = deepcopy(mock_rpc_device.shelly) - new_shelly["relay_operational"] = True - + new_shelly["relay_in_thermostat"] = False monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) From 12902b8a6828134308b60cacca63bf4e695f7f47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 17:45:27 +0100 Subject: [PATCH 044/927] Add NodeStrClass.__voluptuous_compile__ (#104808) --- homeassistant/util/yaml/objects.py | 7 +++++++ tests/util/yaml/test_init.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 990956ec908..a4b243a4b4a 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -8,6 +8,7 @@ import unittest from unittest.mock import patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -615,3 +616,20 @@ def test_string_annotated(try_both_loaders) -> None: getattr(value, "__config_file__", None) == expected_annotations[key][1][0] ) assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"}) From 99523c96a22dd00883b525634a63c6bac5d3ba02 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Nov 2023 17:49:31 +0100 Subject: [PATCH 045/927] Update frontend to 20231130.0 (#104816) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7a587d56d74..b6668383b54 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231129.1"] + "requirements": ["home-assistant-frontend==20231130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 75209de5996..7dad258068d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 33b4778951d..9566156f7ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c774e2e538..917da82ee30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 9447d954d6ab66e1063ef7b91a837bbf47444a61 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Thu, 30 Nov 2023 10:14:22 -0700 Subject: [PATCH 046/927] Add codeowner to vesync (#104809) --- CODEOWNERS | 4 ++-- homeassistant/components/vesync/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d41975259b5..6645a6f06a3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1400,8 +1400,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index fb892acfd4f..ff3f56dd184 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,7 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], + "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", From abc05451a280ba675e654e0cb8d8c6c925dd2a87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 18:14:48 +0100 Subject: [PATCH 047/927] Restore renamed yaml loader classes and warn when used (#104818) --- homeassistant/util/yaml/loader.py | 25 ++++++++++++++ tests/util/yaml/test_init.py | 55 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index fbffae448b2..e8f4a734bdb 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -136,6 +137,18 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLoader(FastSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + FastSafeLoader.__init__(*args, **kwargs) + + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -145,6 +158,18 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLineLoader(PythonSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + PythonSafeLoader.__init__(*args, **kwargs) + + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a4b243a4b4a..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,11 +1,12 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -585,6 +586,58 @@ async def test_loading_actual_file_with_syntax_error( await hass.async_add_executor_job(load_yaml_config_file, fixture_path) +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + message: str, +) -> None: + """Test instantiating the deprecated yaml loaders logs a warning.""" + with pytest.raises(TypeError), patch( + "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() + ): + loader_class() + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + + def test_string_annotated(try_both_loaders) -> None: """Test strings are annotated with file + line.""" conf = ( From f50cd5ab5e46319f2cd495a7c9de9effc8c37073 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 30 Nov 2023 17:17:34 +0000 Subject: [PATCH 048/927] Bump evohome-async to 0.4.9 (#103660) --- homeassistant/components/evohome/__init__.py | 67 ++++++++----------- homeassistant/components/evohome/climate.py | 43 +++++++----- .../components/evohome/manifest.json | 2 +- .../components/evohome/water_heater.py | 9 +-- requirements_all.txt | 2 +- 5 files changed, 60 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f4ceaf2c48c..9c33b0fbf31 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -190,14 +190,14 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict, dict | None]: + async def load_auth_tokens(store) -> tuple[dict[str, str | dt], dict[str, str]]: app_storage = await store.async_load() tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be corrupt await store.async_save({}) - return ({}, None) + return ({}, {}) # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( @@ -205,7 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ): tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) - user_data = tokens.pop(USER_DATA, None) + user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) @@ -214,7 +214,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v2 = evohomeasync2.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], - **tokens, + **tokens, # type: ignore[arg-type] session=async_get_clientsession(hass), ) @@ -253,7 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v1 = evohomeasync.EvohomeClient( client_v2.username, client_v2.password, - user_data=user_data, + session_id=user_data.get("sessionId") if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -425,7 +425,7 @@ class EvoBroker: self.tcs_utc_offset = timedelta( minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] ) - self.temps: dict[str, Any] | None = {} + self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" @@ -441,14 +441,12 @@ class EvoBroker: ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), } - if self.client_v1 and self.client_v1.user_data: - user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "userInfo": {"userID": user_id}, - "sessionId": self.client_v1.user_data["sessionId"], - } + "sessionId": self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 else: - app_storage[USER_DATA] = None + app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) @@ -468,16 +466,13 @@ class EvoBroker: async def _update_v1_api_temps(self, *args, **kwargs) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 + assert self.client_v1 # mypy check - def get_session_id(client_v1) -> str | None: - user_data = client_v1.user_data if client_v1 else None - return user_data.get("sessionId") if user_data else None - - session_id = get_session_id(self.client_v1) + session_id = self.client_v1.broker.session_id # maybe receive a new session_id? + self.temps = {} # these are now stale, will fall back to v2 temps try: - temps = list(await self.client_v1.temperatures(force_refresh=True)) + temps = await self.client_v1.get_temperatures() except evohomeasync.InvalidSchema as exc: _LOGGER.warning( @@ -489,7 +484,7 @@ class EvoBroker: ), exc, ) - self.temps = self.client_v1 = None + self.client_v1 = None except evohomeasync.EvohomeError as exc: _LOGGER.warning( @@ -501,7 +496,6 @@ class EvoBroker: ), exc, ) - self.temps = None # these are now stale, will fall back to v2 temps else: if ( @@ -513,19 +507,20 @@ class EvoBroker: "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.temps = self.client_v1 = None + self.client_v1 = None else: self.temps = {str(i["id"]): i["temp"] for i in temps} finally: - if session_id != get_session_id(self.client_v1): + if self.client_v1 and session_id != self.client_v1.broker.session_id: await self.save_auth_tokens() _LOGGER.debug("Temperatures = %s", self.temps) async def _update_v2_api_state(self, *args, **kwargs) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token + + access_token = self.client.access_token # maybe receive a new token? loc_idx = self.params[CONF_LOCATION_IDX] try: @@ -536,9 +531,9 @@ class EvoBroker: async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status) - - if access_token != self.client.access_token: - await self.save_auth_tokens() + finally: + if access_token != self.client.access_token: + await self.save_auth_tokens() async def async_update(self, *args, **kwargs) -> None: """Get the latest state data of an entire Honeywell TCC Location. @@ -562,6 +557,8 @@ class EvoDevice(Entity): _attr_should_poll = False + _evo_id: str + def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device @@ -623,18 +620,10 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if self._evo_device.TYPE == "domesticHotWater": - dev_id = self._evo_device.dhwId - else: - dev_id = self._evo_device.zoneId - if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: - return self._evo_broker.temps[dev_id] - - if self._evo_device.temperatureStatus["isAvailable"]: - return self._evo_device.temperatureStatus["temperature"] - - return None + if self._evo_broker.temps.get(self._evo_id) is not None: + return self._evo_broker.temps[self._evo_id] + return self._evo_device.temperature @property def setpoints(self) -> dict[str, Any]: @@ -679,7 +668,7 @@ class EvoChild(EvoDevice): switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day + assert switchpoint_time_of_day # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index fb608262a7d..dea5676d332 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -150,6 +150,7 @@ class EvoZone(EvoChild, EvoClimateEntity): self._attr_unique_id = f"{evo_device.zoneId}z" else: self._attr_unique_id = evo_device.zoneId + self._evo_id = evo_device.zoneId self._attr_name = evo_device.name @@ -189,24 +190,27 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO - is_off = self.target_temperature <= self.min_temp - return HVACMode.OFF if is_off else HVACMode.HEAT + if self.target_temperature is None: + return None + if self.target_temperature <= self.min_temp: + return HVACMode.OFF + return HVACMode.HEAT @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature of a Zone.""" - return self._evo_device.setpointStatus["targetHeatTemperature"] + return self._evo_device.target_heat_temperature @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property def min_temp(self) -> float: @@ -214,7 +218,7 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: @@ -222,17 +226,17 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + elif self._evo_device.mode == EVO_TEMPOVER: until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) until = dt_util.as_utc(until) if until else None @@ -272,7 +276,7 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] + temperature = self._evo_device.target_heat_temperature if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() @@ -311,6 +315,7 @@ class EvoController(EvoClimateEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.systemId + self._evo_id = evo_device.systemId self._attr_name = evo_device.location.name modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] @@ -352,7 +357,7 @@ class EvoController(EvoClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - tcs_mode = self._evo_tcs.systemModeStatus["mode"] + tcs_mode = self._evo_tcs.system_mode return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT @property @@ -362,16 +367,18 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperatureStatus["temperature"] + z.temperature for z in self._evo_tcs.zones.values() - if z.temperatureStatus["isAvailable"] + if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + if not self._evo_tcs.system_mode: + return None + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 58efb2c25b2..769c8e597cd 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.6"] + "requirements": ["evohome-async==0.4.9"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 5d49e9b46ec..51617bdf1cf 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -68,6 +68,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.dhwId + self._evo_id = evo_device.dhwId self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE @@ -79,15 +80,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self): """Return True if away mode is on.""" - is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF - is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF + is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 9566156f7ea..8ed260e654f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -789,7 +789,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.6 +evohome-async==0.4.9 # homeassistant.components.faa_delays faadelays==2023.9.1 From 689c0d14ec0546509de96e0650398da982bf080e Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 19:19:10 +0200 Subject: [PATCH 049/927] Added typing for return value for async_migrate func. (#104828) --- homeassistant/components/wirelesstag/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index f95337dbaf4..cfbdb6bdc92 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -128,7 +128,9 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) -def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: """Migrate old unique id to new one with use of tag's uuid.""" registry = er.async_get(hass) new_unique_id = f"{tag.uuid}_{key}" From 4bc1e5075a9f5a00d5d1723d69e3d0b623b239a8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 18:45:04 +0100 Subject: [PATCH 050/927] Revert "Add Komfovent (#95722)" (#104819) --- .coveragerc | 2 - CODEOWNERS | 2 - .../components/komfovent/__init__.py | 34 ---- homeassistant/components/komfovent/climate.py | 91 --------- .../components/komfovent/config_flow.py | 74 ------- homeassistant/components/komfovent/const.py | 3 - .../components/komfovent/manifest.json | 9 - .../components/komfovent/strings.json | 22 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/komfovent/__init__.py | 1 - tests/components/komfovent/conftest.py | 14 -- .../components/komfovent/test_config_flow.py | 189 ------------------ 15 files changed, 454 deletions(-) delete mode 100644 homeassistant/components/komfovent/__init__.py delete mode 100644 homeassistant/components/komfovent/climate.py delete mode 100644 homeassistant/components/komfovent/config_flow.py delete mode 100644 homeassistant/components/komfovent/const.py delete mode 100644 homeassistant/components/komfovent/manifest.json delete mode 100644 homeassistant/components/komfovent/strings.json delete mode 100644 tests/components/komfovent/__init__.py delete mode 100644 tests/components/komfovent/conftest.py delete mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 29b48e439ea..9e5044dff64 100644 --- a/.coveragerc +++ b/.coveragerc @@ -636,8 +636,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 6645a6f06a3..33587dec721 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -665,8 +665,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21..00000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - 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.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2..00000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6..00000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c41..00000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5..00000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json deleted file mode 100644 index 074754c1fe0..00000000000 --- a/homeassistant/components/komfovent/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30c884249a9..e83a2a74405 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -247,7 +247,6 @@ FLOWS = { "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 394b40ac630..56b0fa4ef9d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2892,12 +2892,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 8ed260e654f..11cad3a4536 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,9 +1131,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 917da82ee30..badde74823a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,9 +888,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327..00000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py deleted file mode 100644 index d9cb0950c74..00000000000 --- a/tests/components/komfovent/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Common fixtures for the Komfovent 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.komfovent.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3..00000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result From d597cfec4960cbcae80ead57f7e21c9ac75cfa94 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:18 +0100 Subject: [PATCH 051/927] Improve decorator type annotations (#104821) --- homeassistant/components/guardian/__init__.py | 8 +++++--- homeassistant/components/rainmachine/__init__.py | 13 ++++++++++--- homeassistant/components/simplisafe/__init__.py | 6 ++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index d7a9fe4e836..bd2cb8c96de 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -170,7 +170,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def call_with_data(func: Callable) -> Callable: + def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c29154a941c..fde9b945e53 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from functools import partial, wraps @@ -326,10 +326,17 @@ async def async_setup_entry( # noqa: C901 entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - def call_with_controller(update_programs_and_zones: bool = True) -> Callable: + def call_with_controller( + update_programs_and_zones: bool = True, + ) -> Callable[ + [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]], + Callable[[ServiceCall], Coroutine[Any, Any, None]], + ]: """Hydrate a service call with the appropriate controller.""" - def decorator(func: Callable) -> Callable[..., Awaitable]: + def decorator( + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" @wraps(func) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7b57fa1fc32..b1bd2c8e9d6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from datetime import timedelta from typing import Any, cast @@ -336,7 +336,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def extract_system(func: Callable) -> Callable: + def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" async def wrapper(call: ServiceCall) -> None: From 9bdb7134087a4d61f6e5ac3c7c2df382ece6bbce Mon Sep 17 00:00:00 2001 From: Mappenhei <124632181+Mappenhei@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:52 +0100 Subject: [PATCH 052/927] Add Humidity device class to LaCross humidity sensor (#104814) --- homeassistant/components/lacrosse/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: From 8e2f4a347c9043d8aef52ed6213ca71f0d8ccdc7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 18:46:18 +0100 Subject: [PATCH 053/927] Address late review for the host field description in Shelly integration (#104815) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 49c66a56459..9230ae605e0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to control." + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { From 6ffc298986fc55cb9c1e8bf13ff5f392e036ea8e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:47:18 +0100 Subject: [PATCH 054/927] Improve decorator type annotations [matter] (#104822) --- homeassistant/components/matter/api.py | 31 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 7b4b7d35b7f..227d0c73e89 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,9 +1,9 @@ """Handle websocket api for Matter.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any +from typing import Any, Concatenate, ParamSpec from matter_server.common.errors import MatterError import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter from .helpers import get_matter +_P = ParamSpec("_P") + ID = "id" TYPE = "type" @@ -28,12 +30,19 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_set_wifi_credentials) -def async_get_matter_adapter(func: Callable) -> Callable: +def async_get_matter_adapter( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to get the MatterAdapter.""" @wraps(func) async def _get_matter( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide the Matter client to the function.""" matter = get_matter(hass) @@ -43,7 +52,15 @@ def async_get_matter_adapter(func: Callable) -> Callable: return _get_matter -def async_handle_failed_command(func: Callable) -> Callable: +def async_handle_failed_command( + func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate function to handle MatterError and send relevant error.""" @wraps(func) @@ -51,8 +68,8 @@ def async_handle_failed_command(func: Callable) -> Callable: hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - *args: Any, - **kwargs: Any, + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle MatterError within function and send relevant error.""" try: From 46ba62a3c0dcbe575864005a90ede7a2eaec1a15 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:47:58 +0100 Subject: [PATCH 055/927] Improve decorator type annotations [sabnzbd] (#104823) --- homeassistant/components/sabnzbd/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index babdbc573bd..b1d118e6f75 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,8 +1,9 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging +from typing import Any from pysabnzbd import SabnzbdApiException import voluptuous as vol @@ -189,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_device_identifiers(hass, entry) @callback - def extract_api(func: Callable) -> Callable: + def extract_api( + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" async def wrapper(call: ServiceCall) -> None: From 2496c275c8cdbd3c1f3bb2c5676cf04021b7c61b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:50:31 +0100 Subject: [PATCH 056/927] Improve decorator type annotations [core] (#104826) --- homeassistant/components/frontend/storage.py | 13 ++++++++++--- homeassistant/components/hassio/repairs.py | 10 ++++++++-- homeassistant/helpers/schema_config_entry_flow.py | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 82f169dc6c9..91646dcb745 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,7 @@ """API for persistent storage for the frontend.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps from typing import Any @@ -50,12 +50,19 @@ async def async_user_store( return store, data[user_id] -def with_store(orig_func: Callable) -> Callable: +def with_store( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to provide data.""" @wraps(orig_func) async def with_store_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8337405641c..fcfe23dda6e 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,6 +1,7 @@ """Repairs implementation for supervisor integration.""" +from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -116,7 +117,12 @@ class SupervisorIssueRepairFlow(RepairsFlow): return self.async_create_entry(data={}) @staticmethod - def _async_step(suggestion: Suggestion) -> Callable: + def _async_step( + suggestion: Suggestion, + ) -> Callable[ + [SupervisorIssueRepairFlow, dict[str, str] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler for a suggestion.""" async def _async_step( diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index dcf7f07bf6b..2bbad0ed63a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -331,7 +331,12 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): return cls.options_flow is not None @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( @@ -421,7 +426,12 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): setattr(self, "async_setup_preview", async_setup_preview) @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( From fed8e5e8739f0a9a3eb94d90b66ff2dafaf81ab8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Nov 2023 18:51:23 +0100 Subject: [PATCH 057/927] Always create APCUPS device (#104716) --- homeassistant/components/apcupsd/coordinator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 321da56095a..ae4c94a9382 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -9,6 +9,7 @@ from typing import Final from apcaccess import status +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): updates from the server. """ + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" super().__init__( @@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): return self.data.get("SERIALNO") @property - def device_info(self) -> DeviceInfo | None: + def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" - if not self.ups_serial_no: - return None - return DeviceInfo( - identifiers={(DOMAIN, self.ups_serial_no)}, + identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)}, model=self.ups_model, manufacturer="APC", name=self.ups_name if self.ups_name else "APC UPS", From ca9d58c442896aaa3c9cf0b00633c1b2933e5722 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:06:36 +0100 Subject: [PATCH 058/927] Improve decorator type annotations [sensibo] (#104824) --- homeassistant/components/sensibo/entity.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9f20c051576..0a60fc4a85d 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -23,22 +23,24 @@ def async_handle_api_call( ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" - async def wrap_api_call(*args: Any, **kwargs: Any) -> None: + async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False try: async with asyncio.timeout(TIMEOUT): - res = await function(*args, **kwargs) + res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: raise HomeAssistantError from err - LOGGER.debug("Result %s for entity %s with arguments %s", res, args[0], kwargs) - entity: SensiboDeviceBaseEntity = args[0] + LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: raise HomeAssistantError(f"Could not execute service for {entity.name}") - if kwargs.get("key") is not None and kwargs.get("value") is not None: - setattr(entity.device_data, kwargs["key"], kwargs["value"]) - LOGGER.debug("Debug check key %s is now %s", kwargs["key"], kwargs["value"]) + if ( + isinstance(key := kwargs.get("key"), str) + and (value := kwargs.get("value")) is not None + ): + setattr(entity.device_data, key, value) + LOGGER.debug("Debug check key %s is now %s", key, value) entity.async_write_ha_state() await entity.coordinator.async_request_refresh() From cd1ee70707a792e461e972d3d33dfd414113c74a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 19:58:33 +0100 Subject: [PATCH 059/927] Use orjson to load HomeWizard test fixtures (#104832) --- tests/components/homewizard/conftest.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index e778c82928b..0c24d9daebe 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,6 +1,5 @@ """Fixtures for HomeWizard integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError @@ -11,7 +10,7 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @pytest.fixture @@ -35,22 +34,22 @@ def mock_homewizardenergy( client = homewizard.return_value client.device.return_value = Device.from_dict( - json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) ) if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): client.state.return_value = State.from_dict( - json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) ) else: client.state.side_effect = NotFoundError if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): client.system.return_value = System.from_dict( - json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) ) else: client.system.side_effect = NotFoundError From 4829b21fd0d78477711153b335ae915f20ed0daa Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 20:08:58 +0100 Subject: [PATCH 060/927] Update Matter test fixtures to schema version 5 (#104829) --- .../fixtures/config_entry_diagnostics.json | 68 ++++----- .../config_entry_diagnostics_redacted.json | 68 ++++----- .../nodes/color-temperature-light.json | 114 +++++++------- .../fixtures/nodes/device_diagnostics.json | 76 +++++----- .../matter/fixtures/nodes/dimmable-light.json | 80 +++++----- .../fixtures/nodes/door-lock-with-unbolt.json | 142 +++++++++--------- .../matter/fixtures/nodes/door-lock.json | 142 +++++++++--------- .../fixtures/nodes/eve-contact-sensor.json | 120 +++++++-------- .../fixtures/nodes/extended-color-light.json | 114 +++++++------- .../matter/fixtures/nodes/flow-sensor.json | 12 +- .../fixtures/nodes/generic-switch-multi.json | 25 ++- .../matter/fixtures/nodes/generic-switch.json | 12 +- .../fixtures/nodes/humidity-sensor.json | 12 +- .../matter/fixtures/nodes/light-sensor.json | 12 +- .../fixtures/nodes/occupancy-sensor.json | 12 +- .../fixtures/nodes/on-off-plugin-unit.json | 12 +- .../fixtures/nodes/onoff-light-alt-name.json | 72 ++++----- .../fixtures/nodes/onoff-light-no-name.json | 72 ++++----- .../matter/fixtures/nodes/onoff-light.json | 80 +++++----- .../fixtures/nodes/pressure-sensor.json | 12 +- .../matter/fixtures/nodes/switch-unit.json | 12 +- .../fixtures/nodes/temperature-sensor.json | 12 +- .../matter/fixtures/nodes/thermostat.json | 124 +++++++-------- .../fixtures/nodes/window-covering_full.json | 120 +++++++-------- .../fixtures/nodes/window-covering_lift.json | 120 +++++++-------- .../nodes/window-covering_pa-lift.json | 99 ++++++------ .../nodes/window-covering_pa-tilt.json | 120 +++++++-------- .../fixtures/nodes/window-covering_tilt.json | 120 +++++++-------- 28 files changed, 990 insertions(+), 994 deletions(-) diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], From ea732349c9bc93d09b23dcd966f4dce93ed5b91f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Thu, 30 Nov 2023 14:19:02 -0700 Subject: [PATCH 061/927] Vesync constant cleanup (#104842) --- homeassistant/components/vesync/const.py | 12 ++++++++++++ homeassistant/components/vesync/fan.py | 12 +----------- homeassistant/components/vesync/light.py | 9 +-------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index a0e5b9da52e..b2fd090e781 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -18,9 +18,21 @@ DEV_TYPE_TO_HA = { "ESWL01": "switch", "ESWL03": "switch", "ESO15-TB": "outlet", + "LV-PUR131S": "fan", + "Core200S": "fan", + "Core300S": "fan", + "Core400S": "fan", + "Core600S": "fan", + "Vital200S": "fan", + "Vital100S": "fan", + "ESD16": "walldimmer", + "ESWD16": "walldimmer", + "ESL100": "bulb-dimmable", + "ESL100CW": "bulb-tunable-white", } SKU_TO_BASE_DEVICE = { + # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 326e7daf12c..22983054dc9 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -17,20 +17,10 @@ from homeassistant.util.percentage import ( ) from .common import VeSyncDevice -from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "Vital200S": "fan", - "Vital100S": "fan", -} - FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e6cc979e808..040e9d5696d 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,17 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} - async def async_setup_entry( hass: HomeAssistant, From b0f5b78b9a6ea1191ff85354d5c57c50976a684c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 30 Nov 2023 16:21:34 -0500 Subject: [PATCH 062/927] Fix Harmony switch removal version (#104838) --- homeassistant/components/harmony/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a3c588c06bb..6b833df9720 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -27,7 +27,7 @@ async def async_setup_entry( hass, DOMAIN, "deprecated_switches", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches", @@ -91,7 +91,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self.hass, DOMAIN, f"deprecated_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches_entity", From 3e95c1c705c5edb1ae7f8cabd3a6ae6c2aa7fb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Nov 2023 22:39:54 +0100 Subject: [PATCH 063/927] Bump Mill library (#104836) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf..7bb78eb05e7 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11cad3a4536..2d38ab1ed4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1249,7 +1249,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index badde74823a..6d154d7b45f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -973,7 +973,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 From 99f28c7163aa396b6fa02b9f63781e17a05cff7b Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 30 Nov 2023 16:40:41 -0500 Subject: [PATCH 064/927] Late review updates for Blink (#104755) --- homeassistant/components/blink/__init__.py | 4 +- homeassistant/components/blink/services.py | 79 ++++++++----- homeassistant/components/blink/strings.json | 17 +++ tests/components/blink/test_services.py | 118 +++++++++----------- 4 files changed, 122 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 42ad5cabeb7..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator -from .services import async_setup_services +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Blink.""" - await async_setup_services(hass) + setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 8ea0b6c03a4..12ac0d3b859 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,8 +1,6 @@ """Services for the Blink integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -14,7 +12,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -27,56 +25,67 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string, } ) SERVICE_SEND_PIN_SCHEMA = vol.Schema( - {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } ) SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string, } ) -async def async_setup_services(hass: HomeAssistant) -> None: +def setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - async def collect_coordinators( + def collect_coordinators( device_ids: list[str], ) -> list[BlinkUpdateCoordinator]: - config_entries = list[ConfigEntry]() + config_entries: list[ConfigEntry] = [] registry = dr.async_get(hass) for target in device_ids: device = registry.async_get(target) if device: - device_entries = list[ConfigEntry]() + device_entries: list[ConfigEntry] = [] for entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: device_entries.append(entry) if not device_entries: - raise HomeAssistantError( - f"Device '{target}' is not a {DOMAIN} device" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, ) config_entries.extend(device_entries) else: raise HomeAssistantError( - f"Device '{target}' not found in device registry" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, ) - coordinators = list[BlinkUpdateCoordinator]() + + coordinators: list[BlinkUpdateCoordinator] = [] for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators @@ -85,24 +94,36 @@ async def async_setup_services(hass: HomeAssistant) -> None: camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: await all_cameras[camera_name].video_to_file(video_path) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: @@ -110,11 +131,15 @@ async def async_setup_services(hass: HomeAssistant) -> None: output_dir=clips_dir ) except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -122,7 +147,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index c29c4c765b7..f47f72acb9c 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -101,5 +101,22 @@ } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 438b47f38c5..ccc326dac1f 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_refresh_service_calls( assert mock_blink_api.refresh.call_count == 2 - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_REFRESH, @@ -66,8 +66,6 @@ async def test_refresh_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_video_service_calls( hass: HomeAssistant, @@ -90,18 +88,17 @@ async def test_video_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) caplog.clear() @@ -118,7 +115,7 @@ async def test_video_service_calls( ) mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_VIDEO, @@ -130,22 +127,19 @@ async def test_video_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "Can't write image" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=False) @@ -171,18 +165,17 @@ async def test_picture_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} @@ -202,21 +195,20 @@ async def test_picture_service_calls( mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( side_effect=OSError ) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_RECENT_CLIPS, @@ -228,8 +220,6 @@ async def test_picture_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_pin_service_calls( hass: HomeAssistant, @@ -259,7 +249,7 @@ async def test_pin_service_calls( ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, @@ -267,8 +257,6 @@ async def test_pin_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -325,7 +313,7 @@ async def test_service_called_with_non_blink_device( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, service, @@ -333,8 +321,6 @@ async def test_service_called_with_non_blink_device( blocking=True, ) - assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -382,12 +368,10 @@ async def test_service_called_with_unloaded_entry( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, service, parameters, blocking=True, ) - - assert "Mock Title is not loaded" in str(execinfo) From 00e57ab9a4f8660e27019d4c03f989c3277a55c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 22:43:34 +0100 Subject: [PATCH 065/927] Use deprecated_class decorator in deprecated YAML loader classes (#104835) --- homeassistant/util/yaml/loader.py | 20 +++----------------- tests/util/yaml/test_init.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e8f4a734bdb..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report +from homeassistant.helpers.deprecation import deprecated_class from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,17 +137,10 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - FastSafeLoader.__init__(*args, **kwargs) - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -158,17 +151,10 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - PythonSafeLoader.__init__(*args, **kwargs) - LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..3a2d9b3734d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename="/home/paulus/.homeassistant/custom_components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "message"), + ("loader_class", "new_class"), [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + (yaml.loader.SafeLoader, "FastSafeLoader"), ( yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", + "PythonSafeLoader", ), ], ) @@ -628,14 +628,17 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - message: str, + new_class: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + assert ( + f"{loader_class.__name__} was called from hue, this is a deprecated class. " + f"Use {new_class} instead" + ) in caplog.text def test_string_annotated(try_both_loaders) -> None: From 7767bb328d474e60d101b26dd5d30faad4c1eea6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 23:42:51 +0100 Subject: [PATCH 066/927] Filter out zero readings for DSMR enery sensors (#104843) --- homeassistant/components/dsmr/sensor.py | 4 ++++ tests/components/dsmr/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f56e2c3ed33..0fa04dee489 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -799,6 +799,10 @@ class DSMREntity(SensorEntity): float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) ) + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None + return value @staticmethod diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1b7f8efb201..0c71525be48 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -10,6 +10,8 @@ from decimal import Decimal from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock +import pytest + from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -22,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -308,7 +311,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -335,7 +348,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -371,7 +384,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) From 1273bc8ec4332e07aad56a0be85b43530ab33e95 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 1 Dec 2023 07:05:26 +0100 Subject: [PATCH 067/927] Let executor CI test pass under worse conditions (#104849) --- tests/util/test_executor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 076864c65c4..d7731a44b7d 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -88,6 +88,10 @@ async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None iexecutor.shutdown() finish = time.monotonic() - assert finish - start < 1.3 + # Idealy execution time (finish - start) should be < 1.2 sec. + # CI tests might not run in an ideal environment and timing might + # not be accurate, so we let this test pass + # if the duration is below 3 seconds. + assert finish - start < 3.0 iexecutor.shutdown() From 7ec2980e52746328288f13fc61ae7f55d9779279 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 1 Dec 2023 07:14:13 +0100 Subject: [PATCH 068/927] Change pytest fixture scope from core fixtures (#104831) --- tests/components/conftest.py | 2 +- tests/conftest.py | 4 ++-- tests/test_bootstrap.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index c985565b1be..1ebcc864b4b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( diff --git a/tests/conftest.py b/tests/conftest.py index 4050c1cdb6a..fcd8e8b73a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -383,7 +383,7 @@ def reset_hass_threading_local_object() -> Generator[None, None, None]: ha._hass.__dict__.clear() -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" import bcrypt @@ -1544,7 +1544,7 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture +@pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" with patch( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index b98d3d0311f..42d679d7ce6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -40,7 +40,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( From 0232c8dcb06108be351e316e1cb51938ad63f0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Fri, 1 Dec 2023 08:26:07 +0100 Subject: [PATCH 069/927] Add temperature to the light color mode parameter fallbacks (#86026) * Add color temperature to the color mode fallbacks * Manually add ATTR_COLOR_TEMP since ATTR_COLOR_TEMP_KELVIN is pre-parsed * Include the legacy ATTR_COLOR_TEMP attribute in the tests * Apply suggestions from code review Co-authored-by: Erik Montnemery * Add citation for McCamy's approximation formula If still existing, also see page 3 of https://www.st.com/resource/en/application_note/an5638-how-correlated-color-temperature-is-calculated-by-vd6283-stmicroelectronics.pdf * Update homeassistant/util/color.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/light/__init__.py | 39 ++++++++++++++++++++++ homeassistant/util/color.py | 12 +++++++ tests/components/light/test_init.py | 37 ++++++++++++++++++++ tests/util/test_color.py | 9 +++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890..3bb3797c284 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -500,6 +500,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None if ColorMode.RGBW in supported_color_modes: @@ -515,6 +523,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +545,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +565,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +589,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155..4520a62a5d8 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -576,6 +576,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 675057899b0..962c5500f06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 7c5e959aabc..a7e6ba9ab46 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -270,6 +270,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" From 450bc8dd2ca5798e9ec537eceea2f4545bf57518 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:18:34 +0100 Subject: [PATCH 070/927] Ping to Qnap: add host field description (#104859) --- homeassistant/components/ping/strings.json | 3 +++ homeassistant/components/progettihwsw/strings.json | 3 +++ homeassistant/components/qnap/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 31441df7736..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." } } }, diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, From 742e2dbbe400d9a3f66d2c5dfd3e1605c842e0e2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Dec 2023 12:26:18 +0100 Subject: [PATCH 071/927] Reolink schedule update after firmware update (#104867) --- homeassistant/components/reolink/update.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index a75af46e81e..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -100,3 +105,16 @@ class ReolinkUpdateEntity( ) from err finally: self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() From 970751a635b8d6acbcbf96a2558487cdd4d4eebe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Dec 2023 04:44:58 -0700 Subject: [PATCH 072/927] Bump bluetooth-data-tools to 0.16.0 (#104854) --- 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/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c39c28b13f7..b4975e61507 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.15.0", + "bluetooth-data-tools==1.16.0", "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7eca285681d..db9cd9ba72c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.15.0", + "bluetooth-data-tools==1.16.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 9fd407b1636..a90b5a71c2d 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.15.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.16.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6ecd4ed636e..ca46565b773 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.15.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.16.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index b18716d8020..d894b18f545 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0"] + "requirements": ["bluetooth-data-tools==1.16.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7dad258068d..a219df6fd98 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index 2d38ab1ed4d..f72fd5c1898 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d154d7b45f..5672c4a8259 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 # homeassistant.components.bond bond-async==0.2.1 From dc708b8a1880e0013a41780cea208c15e1a0364a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 1 Dec 2023 15:58:15 +0100 Subject: [PATCH 073/927] Fix handling of unrecognized mimetypes in Synology DSM photos integration (#104848) --- homeassistant/components/synology_dsm/media_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( From 5014e2f0c735c0750004c28157c83cda391fc4a7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:42:50 +0100 Subject: [PATCH 074/927] Fix powerwall typing (#104875) --- homeassistant/components/powerwall/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 33395f5fe6a..8587101a42a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -122,9 +122,9 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" http_session = requests.Session() - ip_address = entry.data[CONF_IP_ADDRESS] + ip_address: str = entry.data[CONF_IP_ADDRESS] - password = entry.data.get(CONF_PASSWORD) + password: str | None = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(ip_address, http_session=http_session) try: base_info = await hass.async_add_executor_job( @@ -184,7 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str + power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: From e724414475d1cf7edfd1431b882397c25eb079c2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:45:53 +0100 Subject: [PATCH 075/927] SamsungTV to Snapcast: add host field description (#104862) --- homeassistant/components/samsungtv/strings.json | 3 +++ homeassistant/components/sfr_box/strings.json | 3 +++ homeassistant/components/sma/strings.json | 3 +++ homeassistant/components/snapcast/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, From a3dd2b8ea9f3f73f54905be79be9da237b07cf55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Dec 2023 10:57:09 -1000 Subject: [PATCH 076/927] Migrate to using faster monotonic_time_coarse from bluetooth-data-tools (#104882) --- .../components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- homeassistant/components/bluetooth/base_scanner.py | 2 +- homeassistant/components/bluetooth/manager.py | 2 +- homeassistant/components/bluetooth/models.py | 3 +-- homeassistant/components/bluetooth/util.py | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978..174e5c66ce8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e9..3a13dda28a8 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 637ebbaf867..21935dab6ef 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -14,6 +14,7 @@ from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -25,7 +26,6 @@ from homeassistant.core import ( ) from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse from . import models from .const import ( diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce047747a0c..e9f490285c9 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -16,6 +16,7 @@ from bluetooth_adapters import ( AdapterDetails, BluetoothAdapters, ) +from bluetooth_data_tools import monotonic_time_coarse from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -27,7 +28,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994..1b8c12c6eb3 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -7,10 +7,9 @@ from enum import Enum from typing import TYPE_CHECKING, Final from bleak import BaseBleakClient +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: from .manager import BluetoothManager diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c..f276b6b51e5 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -3,9 +3,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage From 381036e46ac585f8b34c0eb91b1a9d24b9362019 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Dec 2023 14:10:44 +0100 Subject: [PATCH 077/927] Reolink only fetch data for enabled entities (#104157) --- homeassistant/components/reolink/entity.py | 11 +++++++++++ homeassistant/components/reolink/host.py | 4 +++- homeassistant/components/reolink/light.py | 3 +++ homeassistant/components/reolink/number.py | 22 ++++++++++++++++++++++ homeassistant/components/reolink/select.py | 5 +++++ homeassistant/components/reolink/sensor.py | 2 ++ homeassistant/components/reolink/switch.py | 17 +++++++++++++++++ 7 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 584b380f391..8da64991c27 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -24,6 +24,7 @@ _T = TypeVar("_T") class ReolinkChannelEntityDescription(EntityDescription): """A class that describes entities for a camera channel.""" + cmd_key: str | None = None supported: Callable[[Host, int], bool] = lambda api, ch: True @@ -31,6 +32,7 @@ class ReolinkChannelEntityDescription(EntityDescription): class ReolinkHostEntityDescription(EntityDescription): """A class that describes host entities.""" + cmd_key: str | None = None supported: Callable[[Host], bool] = lambda api: True @@ -84,6 +86,15 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..fe53639822f 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -60,6 +60,8 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -311,7 +313,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index b2d0402b1b9..8df69b156ad 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -41,6 +41,7 @@ class ReolinkLightEntityDescription( LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", supported=lambda api, ch: api.supported(ch, "floodLight"), @@ -51,6 +52,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", + cmd_key="GetIrLights", translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, @@ -60,6 +62,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6a89eabba2b..aaf549453ed 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -41,6 +41,7 @@ class ReolinkNumberEntityDescription( NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -53,6 +54,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -68,6 +70,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -80,6 +83,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -92,6 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -105,6 +110,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -117,6 +123,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -131,6 +138,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -145,6 +153,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -159,6 +168,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -175,6 +185,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_animal_sensititvity", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -189,6 +200,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -205,6 +217,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -221,6 +234,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -237,6 +251,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -255,6 +270,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_animal_delay", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -271,6 +287,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -284,6 +301,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -297,6 +315,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -310,6 +329,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -325,6 +345,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -338,6 +359,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="day_night_switch_threshold", + cmd_key="GetIsp", translation_key="day_night_switch_threshold", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 3d75b08b5d1..eb2ea58cc40 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -44,6 +44,7 @@ class ReolinkSelectEntityDescription( SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -54,6 +55,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -72,6 +74,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -84,6 +87,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -94,6 +98,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 3a5da97dc61..5eef880fc91 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -52,6 +52,7 @@ class ReolinkHostSensorEntityDescription( SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -64,6 +65,7 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index fbb8922188d..352ba7a1103 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -50,6 +50,7 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -59,6 +60,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -68,6 +70,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -77,6 +80,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -86,6 +90,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -95,6 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -104,6 +110,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -113,6 +120,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -122,6 +130,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -131,6 +140,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -140,6 +150,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -149,6 +160,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -162,6 +174,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -171,6 +184,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -180,6 +194,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -189,6 +204,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -198,6 +214,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, From 06d663d33f8b1235737043de67521190c7d37a9d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Dec 2023 16:35:52 +0100 Subject: [PATCH 078/927] Fix get_events name in calendar strings (#104902) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 57450000199..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -73,7 +73,7 @@ } }, "get_events": { - "name": "Get event", + "name": "Get events", "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { From e9d4a02bb1c11cc4a78280f45eeeeedd50e74e80 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Dec 2023 16:37:44 +0100 Subject: [PATCH 079/927] Adjust roku type hint (#104877) --- homeassistant/components/roku/browse_media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e..acaf2e5adbc 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( From 559e8dfc6935acfc8ae4612616651cfb4848efc9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:57:58 +0100 Subject: [PATCH 080/927] Improve decorator type annotations [esphome] (#104878) --- .../components/esphome/bluetooth/client.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 96f1bce686a..06282749649 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from functools import partial import logging import sys -from typing import Any, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import uuid if sys.version_info < (3, 12): @@ -60,7 +60,9 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) +_ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient") +_R = TypeVar("_R") +_P = ParamSpec("_P") def mac_to_int(address: str) -> int: @@ -68,12 +70,14 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: +def api_error_as_bleak_error( + func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: + self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: # pylint: disable=protected-access try: return await func(self, *args, **kwargs) @@ -107,7 +111,7 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: except APIConnectionError as err: raise BleakError(str(err)) from err - return cast(_WrapFuncType, _async_wrap_bluetooth_operation) + return _async_wrap_bluetooth_operation @dataclass(slots=True) From 5106dd173c54dac06ce13038558e4b9d9cf902a2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:28:56 +0100 Subject: [PATCH 081/927] Fix dsmr zero reconnect interval option could crash HA (#104900) * Fix dsmr zero interval option could crash HA * No change change the options --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 9 ++------- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 18 ++---------------- 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ec0623a9ed6..45332546195 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0fa04dee489..b128f9d3baa 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -48,7 +48,6 @@ from .const import ( CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -647,9 +646,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -663,9 +660,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..5c34fbd9e35 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -475,7 +475,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 512e0822016..231cd65d768 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -99,7 +99,6 @@ async def test_migrate_unique_id( "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 493fd93259f..99513b9a2a8 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -30,7 +30,6 @@ async def test_migrate_gas_to_mbus( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -128,7 +127,6 @@ async def test_migrate_gas_to_mbus_exists( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0c71525be48..d3bfabdc0c6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -52,7 +52,6 @@ async def test_default_setup( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -190,7 +189,6 @@ async def test_setup_only_energy( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -246,7 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "4", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -335,7 +332,6 @@ async def test_v5_meter( "port": "/dev/ttyUSB0", "dsmr_version": "5", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -411,7 +407,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> "port": "/dev/ttyUSB0", "dsmr_version": "5L", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -515,7 +510,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -717,7 +711,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -880,7 +873,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -992,7 +984,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1047,7 +1038,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5S", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1122,7 +1112,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1196,7 +1185,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "dsmr_version": "2.2", "protocol": "dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1224,7 +1212,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1242,6 +1229,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -1252,7 +1240,6 @@ async def test_connection_errors_retry( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1281,6 +1268,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1295,7 +1283,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1378,7 +1365,6 @@ async def test_gas_meter_providing_energy_reading( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } From c9306049b3dcce052a0cf26820435c30ff29c3f1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:30:04 +0100 Subject: [PATCH 082/927] Correct SerialException import in dsmr, firmata, landysgyr_heat_meater and rfxtrx integrations (#104889) * Fix SerialException import in dsmr integration * Fix imports firmata, landysgyr_heat_meter, rfxtrx --- homeassistant/components/dsmr/config_flow.py | 2 +- homeassistant/components/dsmr/sensor.py | 2 +- homeassistant/components/firmata/board.py | 4 ++-- homeassistant/components/firmata/config_flow.py | 4 ++-- homeassistant/components/landisgyr_heat_meter/config_flow.py | 2 +- homeassistant/components/landisgyr_heat_meter/coordinator.py | 2 +- homeassistant/components/rfxtrx/config_flow.py | 2 +- tests/components/dsmr/test_config_flow.py | 2 +- tests/components/firmata/test_config_flow.py | 4 ++-- tests/components/landisgyr_heat_meter/test_config_flow.py | 4 ++-- tests/components/landisgyr_heat_meter/test_sensor.py | 2 +- tests/components/rfxtrx/test_config_flow.py | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766..86a7bee9ef1 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -116,7 +116,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index b128f9d3baa..c280c1359ba 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -648,7 +648,7 @@ async def async_setup_entry( # throttle reconnect attempts await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6..233388d5013 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ class FirmataBoard: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c..f5b7cb5af40 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f..7d03ed2efaf 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b92..db265449f37 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 54a60d34229..12b9290af99 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -643,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 5c34fbd9e35..f7f490ba0dd 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c9..474455fc164 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16..19338d8d576 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd..f05d12e49a2 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8..e5a5c73de39 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" From 7480945465be50cea0da9cbb9956be301db01413 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:44:49 +0100 Subject: [PATCH 083/927] Add number entities for program temperature in ViCare integration (#103960) * add number platform * Update .coveragerc * reset default value * fix default value * cast Any value to float * Apply suggestions from code review * Update strings.json * add translation keys * remove obsolete unique id handling * add eco program * reset type * remove name from entity * Update strings.json * Update strings.json * rename * Apply suggestions from code review Co-authored-by: G Johansson * retype getter * adjust interface * use device classes * check setter * Revert "check setter" This reverts commit 360e33315987c086d923992a4ced888886636627. * remove eco entity * Update strings.json * add eco sensor entity * Revert "add eco sensor entity" This reverts commit d64b38c548d0cdc56571b7c75fa026b1bb755f42. * Update strings.json --------- Co-authored-by: G Johansson --- homeassistant/components/vicare/__init__.py | 2 +- .../components/vicare/binary_sensor.py | 3 ++ homeassistant/components/vicare/number.py | 45 ++++++++++++++++++- homeassistant/components/vicare/strings.json | 3 -- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 76de3a8a7ac..2e3284c37c4 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -41,7 +41,7 @@ _TOKEN_FILENAME = "vicare_token.save" class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] @dataclass() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 525099e7d4e..95a4bcdc9f0 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,7 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -40,6 +41,8 @@ class ViCareBinarySensorEntityDescription( ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 5511f2a5294..965b5a619fc 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -19,7 +19,11 @@ from PyViCare.PyViCareUtils import ( ) from requests.exceptions import ConnectionError as RequestConnectionError -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -37,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" + value_getter: Callable[[PyViCareDevice], float] value_setter: Callable[[PyViCareDevice, float], Any] | None = None min_value_getter: Callable[[PyViCareDevice], float | None] | None = None max_value_getter: Callable[[PyViCareDevice], float | None] | None = None @@ -49,6 +54,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( @@ -77,6 +83,42 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=3.5, native_step=0.1, ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), ) @@ -149,6 +191,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): self._attr_native_value = self.entity_description.value_getter( self._api ) + if min_value := _get_value( self.entity_description.min_value_getter, self._api ): diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 47ee60b2ea8..6c08215a9c1 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,9 +80,6 @@ }, "comfort_temperature": { "name": "Comfort temperature" - }, - "eco_temperature": { - "name": "Eco temperature" } }, "sensor": { From b48ad268b52942df38640e21d3ef273d0fe33e8a Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:04:37 +0000 Subject: [PATCH 084/927] Add alarm sensor to Aurora ABB (#104503) * Add new sensors * Add strings * Fix tests * Only add alarm sensor, & refactor * Address review comments * Address review comments. * Fix ruff --- .../components/aurora_abb_powerone/__init__.py | 2 ++ .../components/aurora_abb_powerone/sensor.py | 10 ++++++++++ .../components/aurora_abb_powerone/strings.json | 7 +++++-- tests/components/aurora_abb_powerone/test_sensor.py | 12 +++++++++--- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 43e3bd2ad5c..39abba4ada5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): power_watts = self.client.measure(3, True) temperature_c = self.client.measure(21) energy_wh = self.client.cumulated_energy(5) + [alarm, *_] = self.client.alarms() except AuroraTimeoutError: self.available = False _LOGGER.debug("No response from inverter (could be dark)") @@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): data["instantaneouspower"] = round(power_watts, 1) data["temp"] = round(temperature_c, 1) data["totalenergy"] = round(energy_wh / 1000, 2) + data["alarm"] = alarm self.available = True finally: diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 0e7d0c06a4e..80b0fd656b6 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from aurorapy.mapping import Mapping as AuroraMapping + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,8 +38,16 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502..63ea1cfefd4 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 61521c49b79..a78682ced6d 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -102,6 +104,8 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, @@ -133,7 +137,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraTimeoutError("No response after 3 tries"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -145,7 +149,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -159,7 +163,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraError("No response after 10 seconds"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -174,6 +178,8 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) From 7a9c3819e09ab185e270763a4c66751e44cbb905 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 2 Dec 2023 23:07:06 +0100 Subject: [PATCH 085/927] Add MELCloud token refresh upon firmware upgrade (#104391) * Adding initial setup * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Adding ConfigEntryNotReady exception * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/melcloud/config_flow.py Co-authored-by: G Johansson * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Placing exception handling in setup_entry * Expanding test cases --------- Co-authored-by: G Johansson --- homeassistant/components/melcloud/__init__.py | 32 ++-- .../components/melcloud/config_flow.py | 73 +++++++- .../components/melcloud/strings.json | 9 + tests/components/melcloud/test_config_flow.py | 157 ++++++++++++++++++ 4 files changed, 256 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5..d1ed5cafcbf 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a..b19e268a4c3 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,6 +61,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + entry: config_entries.ConfigEntry | None = None + async def _create_entry(self, username: str, token: str): """Register new entry.""" await self.async_set_unique_id(username) @@ -126,3 +133,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + 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: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + 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_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d..3abb30bf9ac 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1eca..f3d49f3c0bc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From dd9c22672a6b2d47c09aaf4e7091049e23decefe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Dec 2023 13:20:06 -1000 Subject: [PATCH 086/927] Refactor bluetooth scanners for better seperation of concerns (#104909) --- .../components/bluetooth/__init__.py | 8 +- .../components/bluetooth/base_scanner.py | 208 ++++++++++++------ homeassistant/components/bluetooth/scanner.py | 12 +- .../components/esphome/bluetooth/scanner.py | 7 +- .../components/ruuvi_gateway/bluetooth.py | 4 +- .../components/shelly/bluetooth/scanner.py | 7 +- tests/components/bluetooth/test_api.py | 8 +- .../components/bluetooth/test_base_scanner.py | 18 +- .../components/bluetooth/test_diagnostics.py | 4 +- tests/components/bluetooth/test_manager.py | 14 +- tests/components/bluetooth/test_models.py | 12 +- tests/components/bluetooth/test_wrappers.py | 4 +- 12 files changed, 195 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd5..5948d4d1358 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -59,7 +59,11 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice +from .base_scanner import ( + BaseHaScanner, + BluetoothScannerDevice, + HomeAssistantRemoteScanner, +) from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -103,7 +107,7 @@ __all__ = [ "async_scanner_count", "async_scanner_devices_by_address", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 21935dab6ef..f7696c2e90b 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,14 +1,13 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod +import asyncio from collections.abc import Callable, Generator from contextlib import contextmanager from dataclasses import dataclass -import datetime -from datetime import timedelta import logging -from typing import Any, Final +from typing import Any, Final, final from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData @@ -24,8 +23,6 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util from . import models from .const import ( @@ -35,6 +32,7 @@ from .const import ( ) from .models import HaBluetoothConnector +SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) @@ -48,11 +46,10 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" +class BaseHaScanner: + """Base class for high availability BLE scanners.""" __slots__ = ( - "hass", "adapter", "connectable", "source", @@ -63,17 +60,16 @@ class BaseHaScanner(ABC): "_last_detection", "_start_time", "_cancel_watchdog", + "_loop", ) def __init__( self, - hass: HomeAssistant, source: str, adapter: str, connector: HaBluetoothConnector | None = None, ) -> None: """Initialize the scanner.""" - self.hass = hass self.connectable = False self.source = source self.connector = connector @@ -83,13 +79,20 @@ class BaseHaScanner(ABC): self.scanning = True self._last_detection = 0.0 self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None + self._cancel_watchdog: asyncio.TimerHandle | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + @hass_callback + def async_setup(self) -> CALLBACK_TYPE: + """Set up the scanner.""" + self._loop = asyncio.get_running_loop() + return self._unsetup @hass_callback def _async_stop_scanner_watchdog(self) -> None: """Stop the scanner watchdog.""" if self._cancel_watchdog: - self._cancel_watchdog() + self._cancel_watchdog.cancel() self._cancel_watchdog = None @hass_callback @@ -97,12 +100,22 @@ class BaseHaScanner(ABC): """If something has restarted or updated, we need to restart the scanner.""" self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) + self._schedule_watchdog() + + def _schedule_watchdog(self) -> None: + """Schedule the watchdog.""" + loop = self._loop + assert loop is not None + self._cancel_watchdog = loop.call_at( + loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, + self._async_call_scanner_watchdog, + ) + + @final + def _async_call_scanner_watchdog(self) -> None: + """Call the scanner watchdog and schedule the next one.""" + self._async_scanner_watchdog() + self._schedule_watchdog() @hass_callback def _async_watchdog_triggered(self) -> bool: @@ -116,7 +129,7 @@ class BaseHaScanner(ABC): return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: + def _async_scanner_watchdog(self) -> None: """Check if the scanner is running. Override this method if you need to do something else when the watchdog @@ -135,6 +148,10 @@ class BaseHaScanner(ABC): return self.scanning = not self._connecting + @hass_callback + def _unsetup(self) -> None: + """Unset up the scanner.""" + @contextmanager def connecting(self) -> Generator[None, None, None]: """Context manager to track connecting state.""" @@ -183,7 +200,7 @@ class BaseHaScanner(ABC): class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" + """Base class for a high availability remote BLE scanner.""" __slots__ = ( "_new_info_callback", @@ -191,12 +208,11 @@ class BaseHaRemoteScanner(BaseHaScanner): "_discovered_device_timestamps", "_details", "_expire_seconds", - "_storage", + "_cancel_track", ) def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, new_info_callback: Callable[[BluetoothServiceInfoBleak], None], @@ -204,7 +220,7 @@ class BaseHaRemoteScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) + super().__init__(scanner_id, name, connector) self._new_info_callback = new_info_callback self._discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] @@ -215,55 +231,37 @@ class BaseHaRemoteScanner(BaseHaScanner): # Scanners only care about connectable devices. The manager # will handle taking care of availability for non-connectable devices self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - assert models.MANAGER is not None - self._storage = models.MANAGER.storage + self._cancel_track: asyncio.TimerHandle | None = None + + def _cancel_expire_devices(self) -> None: + """Cancel the expiration of old devices.""" + if self._cancel_track: + self._cancel_track.cancel() + self._cancel_track = None + + @hass_callback + def _unsetup(self) -> None: + """Unset up the scanner.""" + self._async_stop_scanner_watchdog() + self._cancel_expire_devices() @hass_callback def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" - if history := self._storage.async_get_advertisement_history(self.source): - self._discovered_device_advertisement_datas = ( - history.discovered_device_advertisement_datas - ) - self._discovered_device_timestamps = history.discovered_device_timestamps - # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) - - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._async_save_history - ) + super().async_setup() + self._schedule_expire_devices() self._async_setup_scanner_watchdog() + return self._unsetup - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel + def _schedule_expire_devices(self) -> None: + """Schedule the expiration of old devices.""" + loop = self._loop + assert loop is not None + self._cancel_expire_devices() + self._cancel_track = loop.call_at(loop.time() + 30, self._async_expire_devices) @hass_callback - def _async_save_history(self, event: Event | None = None) -> None: - """Save the history.""" - self._storage.async_set_advertisement_history( - self.source, - DiscoveredDeviceAdvertisementData( - self.connectable, - self._expire_seconds, - self._discovered_device_advertisement_datas, - self._discovered_device_timestamps, - ), - ) - - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: + def _async_expire_devices(self) -> None: """Expire old devices.""" now = MONOTONIC_TIME() expired = [ @@ -274,6 +272,7 @@ class BaseHaRemoteScanner(BaseHaScanner): for address in expired: del self._discovered_device_advertisement_datas[address] del self._discovered_device_timestamps[address] + self._schedule_expire_devices() @property def discovered_devices(self) -> list[BLEDevice]: @@ -395,9 +394,6 @@ class BaseHaRemoteScanner(BaseHaScanner): """Return diagnostic information about the scanner.""" now = MONOTONIC_TIME() return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), "connectable": self.connectable, "discovered_device_timestamps": self._discovered_device_timestamps, "time_since_last_device_detection": { @@ -405,3 +401,79 @@ class BaseHaRemoteScanner(BaseHaScanner): for address, timestamp in self._discovered_device_timestamps.items() }, } + + +class HomeAssistantRemoteScanner(BaseHaRemoteScanner): + """Home Assistant remote BLE scanner. + + This is the only object that should know about + the hass object. + """ + + __slots__ = ( + "hass", + "_storage", + "_cancel_stop", + ) + + def __init__( + self, + hass: HomeAssistant, + scanner_id: str, + name: str, + new_info_callback: Callable[[BluetoothServiceInfoBleak], None], + connector: HaBluetoothConnector | None, + connectable: bool, + ) -> None: + """Initialize the scanner.""" + self.hass = hass + assert models.MANAGER is not None + self._storage = models.MANAGER.storage + self._cancel_stop: CALLBACK_TYPE | None = None + super().__init__(scanner_id, name, new_info_callback, connector, connectable) + + @hass_callback + def async_setup(self) -> CALLBACK_TYPE: + """Set up the scanner.""" + super().async_setup() + if history := self._storage.async_get_advertisement_history(self.source): + self._discovered_device_advertisement_datas = ( + history.discovered_device_advertisement_datas + ) + self._discovered_device_timestamps = history.discovered_device_timestamps + # Expire anything that is too old + self._async_expire_devices() + + self._cancel_stop = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self._async_save_history + ) + return self._unsetup + + @hass_callback + def _unsetup(self) -> None: + super()._unsetup() + self._async_save_history() + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None + + @hass_callback + def _async_save_history(self, event: Event | None = None) -> None: + """Save the history.""" + self._storage.async_set_advertisement_history( + self.source, + DiscoveredDeviceAdvertisementData( + self.connectable, + self._expire_seconds, + self._discovered_device_advertisement_datas, + self._discovered_device_timestamps, + ), + ) + + async def async_diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about the scanner.""" + diag = await super().async_diagnostics() + diag["storage"] = self._storage.async_get_advertisement_history_as_dict( + self.source + ) + return diag diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 896d9dc7958..712fe1c0d9a 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import datetime import logging import platform from typing import Any @@ -19,7 +18,7 @@ from bleak_retry_connector import restore_discoveries from bluetooth_adapters import DEFAULT_ADDRESS from dbus_fast import InvalidMessageError -from homeassistant.core import HomeAssistant, callback as hass_callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.package import is_docker_env @@ -133,12 +132,13 @@ class HaScanner(BaseHaScanner): """Init bluetooth discovery.""" self.mac_address = address source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) + super().__init__(source, adapter) self.connectable = True self.mode = mode self._start_stop_lock = asyncio.Lock() self._new_info_callback = new_info_callback self.scanning = False + self.hass = hass @property def discovered_devices(self) -> list[BLEDevice]: @@ -153,11 +153,13 @@ class HaScanner(BaseHaScanner): return self.scanner.discovered_devices_and_advertisement_data @hass_callback - def async_setup(self) -> None: + def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" + super().async_setup() self.scanner = create_bleak_scanner( self._async_detection_callback, self.mode, self.adapter ) + return self._unsetup async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" @@ -314,7 +316,7 @@ class HaScanner(BaseHaScanner): await restore_discoveries(self.scanner, self.adapter) @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: + def _async_scanner_watchdog(self) -> None: """Check if the scanner is running.""" if not self._async_watchdog_triggered(): return diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index a54e7af59a6..b4fb12210d3 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -7,11 +7,14 @@ from bluetooth_data_tools import ( parse_advertisement_data_tuple, ) -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback -class ESPHomeScanner(BaseHaRemoteScanner): +class ESPHomeScanner(HomeAssistantRemoteScanner): """Scanner for esphome.""" __slots__ = () diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0..8a154bca019 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -10,7 +10,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, - BaseHaRemoteScanner, + HomeAssistantRemoteScanner, async_get_advertisement_callback, async_register_scanner, ) @@ -22,7 +22,7 @@ from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class RuuviGatewayScanner(BaseHaRemoteScanner): +class RuuviGatewayScanner(HomeAssistantRemoteScanner): """Scanner for Ruuvi Gateway.""" def __init__( diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 7c0dc3c792a..3ada1ce55f5 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,13 +6,16 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback from ..const import LOGGER -class ShellyBLEScanner(BaseHaRemoteScanner): +class ShellyBLEScanner(HomeAssistantRemoteScanner): """Scanner for shelly.""" @callback diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487..30e9554f2af 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_scanner_by_source, async_scanner_devices_by_address, ) @@ -27,7 +27,7 @@ from . import ( async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) assert async_scanner_by_source(hass, "hci2") is hci2_scanner @@ -46,7 +46,7 @@ async def test_async_scanner_devices_by_address_connectable( """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() - class FakeInjectableScanner(BaseHaRemoteScanner): + class FakeInjectableScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -135,7 +135,7 @@ async def test_async_scanner_devices_by_address_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) + scanner = FakeStaticScanner("esp32", "esp32", connector) cancel = manager.async_register_scanner(scanner, False) assert scanner.discovered_devices_and_advertisement_data == { diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d..a39f18e037e 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -13,8 +13,8 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, storage, ) from homeassistant.components.bluetooth.advertisement_tracker import ( @@ -89,7 +89,7 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -173,7 +173,7 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -248,7 +248,7 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -346,7 +346,7 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -418,7 +418,7 @@ async def test_restore_history_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -434,7 +434,7 @@ async def test_restore_history_remote_adapter( cancel() unsetup() - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -470,7 +470,7 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -592,7 +592,7 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): """A fake remote scanner.""" def inject_advertisement( diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f06..8625283266e 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -7,8 +7,8 @@ from bluetooth_adapters import DEFAULT_ADDRESS from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -442,7 +442,7 @@ async def test_diagnostics_remote_adapter( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471..361f0cd008f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,12 +12,12 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_ble_device_from_address, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -56,7 +56,7 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") + hci0_scanner = FakeScanner("hci0", "hci0") cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) yield cancel() @@ -65,7 +65,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") + hci1_scanner = FakeScanner("hci1", "hci1") cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) yield cancel() @@ -562,7 +562,7 @@ async def test_switching_adapters_when_one_goes_away( ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True + hass, FakeScanner("hci2", "hci2"), True ) address = "44:44:33:11:23:45" @@ -612,7 +612,7 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) address = "44:44:33:11:23:45" @@ -704,7 +704,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -877,7 +877,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cb..1d07ab75a48 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -10,9 +10,9 @@ from bleak.backends.scanner import AdvertisementData import pytest from homeassistant.components.bluetooth import ( - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.components.bluetooth.wrappers import ( HaBleakClientWrapper, @@ -158,7 +158,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -271,7 +271,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -336,7 +336,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -439,7 +439,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -553,7 +553,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index f69f8971479..d3c2e1b54db 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -12,9 +12,9 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_get_advertisement_callback, ) from homeassistant.components.bluetooth.usage import ( @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device -class FakeScanner(BaseHaRemoteScanner): +class FakeScanner(HomeAssistantRemoteScanner): """Fake scanner.""" def __init__( From a5fe68c3541f03904a58f61e679c39e8e2757bf2 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 3 Dec 2023 11:15:07 +0100 Subject: [PATCH 087/927] Bump python-holidays to 0.37 (#104937) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7c993e70d0..dd2df87234f 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.36"] + "requirements": ["holidays==0.37"] } diff --git a/requirements_all.txt b/requirements_all.txt index f72fd5c1898..c70e0784897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend home-assistant-frontend==20231130.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5672c4a8259..47b9feb0a7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend home-assistant-frontend==20231130.0 From edb52bb364130e42f5bde4074339ef57c9613c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 00:51:34 -1000 Subject: [PATCH 088/927] Bump zeroconf to 0.128.0 (#104936) --- 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 5eb77b0c41c..8351212f0b8 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.127.0"] + "requirements": ["zeroconf==0.128.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a219df6fd98..7b788c8f78a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.127.0 +zeroconf==0.128.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 c70e0784897..5f811f514a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2813,7 +2813,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47b9feb0a7b..97a41ba9fe4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2108,7 +2108,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 5d6791e412e80fb5136d0e6be71cfb8587c16c54 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 3 Dec 2023 11:53:09 +0100 Subject: [PATCH 089/927] Remove CONF_PRECISION remainder from dsmr (#104919) --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 5 +---- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 20 ++------------------ 6 files changed, 3 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 45332546195..4ac59372deb 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_SERIAL_ID = "serial_id" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index c280c1359ba..722b8eda326 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -46,7 +46,6 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PRECISION, CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, @@ -790,9 +789,7 @@ class DSMREntity(SensorEntity): return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), DEFAULT_PRECISION) # Make sure we do not return a zero value for an energy sensor if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index f7f490ba0dd..422bfa0c35c 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -474,7 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 231cd65d768..b42f26f4ccc 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,7 +98,6 @@ async def test_migrate_unique_id( data={ "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 99513b9a2a8..5e31fa7a82e 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -29,7 +29,6 @@ async def test_migrate_gas_to_mbus( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -126,7 +125,6 @@ async def test_migrate_gas_to_mbus_exists( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index d3bfabdc0c6..419b562f431 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,7 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -188,7 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", } entry_options = { @@ -243,7 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -331,7 +328,6 @@ async def test_v5_meter( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -406,7 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -509,7 +504,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -710,7 +704,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -872,7 +865,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -983,7 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1037,7 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1111,7 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1151,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -1164,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -1184,7 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1211,7 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1239,7 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1282,7 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1364,7 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } From 67784def1337df971d054d2fd3c6587472a6db47 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Dec 2023 11:57:48 +0100 Subject: [PATCH 090/927] Only raise issue if switch used in Logitech Harmony Hub (#104941) --- homeassistant/components/harmony/switch.py | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 6b833df9720..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,15 +23,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: From 244edb488be5d2df0fb250cf7e725e1209cb7e18 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 3 Dec 2023 16:28:53 +0100 Subject: [PATCH 091/927] Add Holiday integration (#103795) * Add Holiday integration * Localize holiday names * Changes based on review feedback * Add tests * Add device info * Bump holidays to 0.36 * Default to Home Assistant country setting * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/config_flow.py Co-authored-by: G Johansson * black * Move time * Stop creating duplicate holiday calendars * Set default language using python-holiday * Use common translation * Set _attr_name to None to fix friendly name * Fix location * Update homeassistant/components/holiday/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update tests/components/holiday/test_init.py Co-authored-by: G Johansson * cleanup * Set up the integration and test the state * Test that configuring more than one instance is rejected * Set default_language to user's language, fallback to country's default language * Improve tests * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Cleanup * Add next year so we don't run out * Update tests/components/holiday/test_init.py Co-authored-by: G Johansson * Cleanup * Set default language in `__init__` * Add strict typing * Change default language: HA's language `en` is `en_US` in holidays, apart from Canada * CONF_PROVINCE can be None * Fix test * Fix default_language * Refactor tests * Province can be None * Add test for translated title * Address feedback * Address feedback * Change test to use service call * Address feedback * Apply suggestions from code review Co-authored-by: G Johansson * Changes based on review feedback * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Add a test if next event is missing * Rebase * Set device to service * Remove not needed translation key --------- Co-authored-by: G Johansson --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/holiday/__init__.py | 20 ++ homeassistant/components/holiday/calendar.py | 134 ++++++++++ .../components/holiday/config_flow.py | 99 ++++++++ homeassistant/components/holiday/const.py | 6 + .../components/holiday/manifest.json | 9 + homeassistant/components/holiday/strings.json | 19 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 4 + requirements_test_all.txt | 4 + tests/components/holiday/__init__.py | 1 + tests/components/holiday/conftest.py | 14 ++ tests/components/holiday/test_calendar.py | 229 ++++++++++++++++++ tests/components/holiday/test_config_flow.py | 128 ++++++++++ tests/components/holiday/test_init.py | 29 +++ 18 files changed, 716 insertions(+) create mode 100644 homeassistant/components/holiday/__init__.py create mode 100644 homeassistant/components/holiday/calendar.py create mode 100644 homeassistant/components/holiday/config_flow.py create mode 100644 homeassistant/components/holiday/const.py create mode 100644 homeassistant/components/holiday/manifest.json create mode 100644 homeassistant/components/holiday/strings.json create mode 100644 tests/components/holiday/__init__.py create mode 100644 tests/components/holiday/conftest.py create mode 100644 tests/components/holiday/test_calendar.py create mode 100644 tests/components/holiday/test_config_flow.py create mode 100644 tests/components/holiday/test_init.py diff --git a/.strict-typing b/.strict-typing index daa4a56dead..daf2baabf67 100644 --- a/.strict-typing +++ b/.strict-typing @@ -152,6 +152,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* diff --git a/CODEOWNERS b/CODEOWNERS index 33587dec721..e618db415c6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -522,6 +522,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger +/tests/components/holiday/ @jrieger /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000..224b1b01294 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + 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.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000..bb9a332cb73 --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000..93ff2772eb8 --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.data = user_input + + selected_country = self.data[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + locale = Locale(self.hass.config.language) + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=self.data) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + locale = Locale(self.hass.config.language) + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000..5d2a567a488 --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000..f73577bddee --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.37", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000..4762a48c659 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e83a2a74405..aa13faaf501 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ FLOWS = { "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 56b0fa4ef9d..636d1030850 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2408,6 +2408,12 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "name": "Holiday", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 05525d03300..83bc95a940b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1281,6 +1281,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +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.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5f811f514a5..c9b8f66647f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -504,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -1013,6 +1016,7 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday holidays==0.37 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97a41ba9fe4..9ba43ff0dbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -438,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -800,6 +803,7 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday holidays==0.37 diff --git a/tests/components/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000..e906586aabc --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/holiday/conftest.py b/tests/components/holiday/conftest.py new file mode 100644 index 00000000000..d9b0d1a5788 --- /dev/null +++ b/tests/components/holiday/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Holiday 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.holiday.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000..06011fb8e6b --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +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, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000..e99d310762e --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000..a044e390a68 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED From c8bb72935d935680ea7a9c5f8bde552f2d8f7dcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 08:53:17 -1000 Subject: [PATCH 092/927] Bump bluetooth-data-tools to 1.17.0 (#104935) --- 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/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b4975e61507..bb052a3a042 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.16.0", + "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index db9cd9ba72c..7be54ad739f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.16.0", + "bluetooth-data-tools==1.17.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 a90b5a71c2d..398fcb95872 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.16.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ca46565b773..440ec427f8d 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.16.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index d894b18f545..ed7fc975d9e 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.16.0"] + "requirements": ["bluetooth-data-tools==1.17.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b788c8f78a..06892125940 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index c9b8f66647f..94895a49444 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ba43ff0dbf..e3424447c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 From 28584ad24056df42d8b85daeea63f6d40b45ba49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 10:00:11 -1000 Subject: [PATCH 093/927] Relocate base Bluetooth scanner code into an external library (#104930) --- .../components/bluetooth/__init__.py | 8 +- .../components/bluetooth/base_scanner.py | 380 +----------------- homeassistant/components/bluetooth/const.py | 44 +- .../components/bluetooth/manifest.json | 3 +- homeassistant/components/bluetooth/models.py | 11 - homeassistant/components/bluetooth/scanner.py | 3 +- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../bluetooth/test_advertisement_tracker.py | 2 +- .../components/bluetooth/test_base_scanner.py | 16 +- tests/components/bluetooth/test_init.py | 4 +- tests/components/bluetooth/test_models.py | 1 - tests/components/bluetooth/test_scanner.py | 32 +- 14 files changed, 51 insertions(+), 460 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5948d4d1358..a0eb263757a 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,7 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from habluetooth import HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -77,12 +78,7 @@ from .const import ( ) from .manager import BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) +from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError from .storage import BluetoothStorage diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index f7696c2e90b..8267a73fd71 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,19 +1,14 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import abstractmethod -import asyncio -from collections.abc import Callable, Generator -from contextlib import contextmanager +from collections.abc import Callable from dataclasses import dataclass -import logging -from typing import Any, Final, final +from typing import Any from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name -from bluetooth_data_tools import monotonic_time_coarse +from bluetooth_adapters import DiscoveredDeviceAdvertisementData +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -25,16 +20,6 @@ from homeassistant.core import ( ) from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) @dataclass(slots=True) @@ -46,363 +31,6 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner: - """Base class for high availability BLE scanners.""" - - __slots__ = ( - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - "_loop", - ) - - def __init__( - self, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: asyncio.TimerHandle | None = None - self._loop: asyncio.AbstractEventLoop | None = None - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - self._loop = asyncio.get_running_loop() - return self._unsetup - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog.cancel() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._schedule_watchdog() - - def _schedule_watchdog(self) -> None: - """Schedule the watchdog.""" - loop = self._loop - assert loop is not None - self._cancel_watchdog = loop.call_at( - loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, - self._async_call_scanner_watchdog, - ) - - @final - def _async_call_scanner_watchdog(self) -> None: - """Call the scanner watchdog and schedule the next one.""" - self._async_scanner_watchdog() - self._schedule_watchdog() - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @hass_callback - def _unsetup(self) -> None: - """Unset up the scanner.""" - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a high availability remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", - "_cancel_track", - ) - - def __init__( - self, - scanner_id: str, - name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - connector: HaBluetoothConnector | None, - connectable: bool, - ) -> None: - """Initialize the scanner.""" - super().__init__(scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - self._cancel_track: asyncio.TimerHandle | None = None - - def _cancel_expire_devices(self) -> None: - """Cancel the expiration of old devices.""" - if self._cancel_track: - self._cancel_track.cancel() - self._cancel_track = None - - @hass_callback - def _unsetup(self) -> None: - """Unset up the scanner.""" - self._async_stop_scanner_watchdog() - self._cancel_expire_devices() - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - self._schedule_expire_devices() - self._async_setup_scanner_watchdog() - return self._unsetup - - def _schedule_expire_devices(self) -> None: - """Schedule the expiration of old devices.""" - loop = self._loop - assert loop is not None - self._cancel_expire_devices() - self._cancel_track = loop.call_at(loop.time() + 30, self._async_expire_devices) - - @hass_callback - def _async_expire_devices(self) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - self._schedule_expire_devices() - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - try: - prev_discovery = self._discovered_device_advertisement_datas[address] - except KeyError: - # We expect this is the rare case and since py3.11+ has - # near zero cost try on success, and we can avoid .get() - # which is slower than [] we use the try/except pattern. - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) - else: - # Merge the new data with the old data - # to function the same as BlueZ which - # merges the dicts on PropertiesChanged - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.name - - if prev_name and (not local_name or len(prev_name) > len(local_name)): - local_name = prev_name - - if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list({*service_uuids, *prev_service_uuids}) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } - - class HomeAssistantRemoteScanner(BaseHaRemoteScanner): """Home Assistant remote BLE scanner. diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec02..fa8efabcb1d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index bb052a3a042..74cec5edfe2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,7 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", - "dbus-fast==2.14.0" + "dbus-fast==2.14.0", + "habluetooth==0.1.0" ] } diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1b8c12c6eb3..48ba021cd6c 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,11 +2,9 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Final -from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -19,15 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - - class BluetoothScanningMode(Enum): """The mode of scanning for bluetooth devices.""" diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 712fe1c0d9a..203bdac68a6 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -16,13 +16,14 @@ from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak_retry_connector import restore_discoveries from bluetooth_adapters import DEFAULT_ADDRESS +from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from dbus_fast import InvalidMessageError from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.package import is_docker_env -from .base_scanner import MONOTONIC_TIME, BaseHaScanner +from .base_scanner import BaseHaScanner from .const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06892125940..f7f67dcbf7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,6 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 +habluetooth==0.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 94895a49444..640c050e05f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -983,6 +983,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.1.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3424447c2f..608a53b46f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -782,6 +782,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.1.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f0..6ae847ba84a 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -352,7 +352,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") + scanner = FakeScanner("new", "fake_adapter") cancel_scanner = async_register_scanner(hass, scanner, False) @callback diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index a39f18e037e..5886cc10aac 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -215,7 +215,7 @@ async def test_remote_scanner_expires_connectable( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -298,7 +298,7 @@ async def test_remote_scanner_expires_non_connectable( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -314,7 +314,7 @@ async def test_remote_scanner_expires_non_connectable( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -515,7 +515,7 @@ async def test_device_with_ten_minute_advertising_interval( ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=new_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) @@ -528,7 +528,7 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=new_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) @@ -562,7 +562,7 @@ async def test_device_with_ten_minute_advertising_interval( "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=missed_advertisement_future_time, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=missed_advertisement_future_time, ): # Fire once for the scanner to expire the device @@ -629,7 +629,7 @@ async def test_scanner_stops_responding( ) # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=failure_reached_time, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -653,7 +653,7 @@ async def test_scanner_stops_responding( failure_reached_time += 1 with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=failure_reached_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f5..b24bb97e1e3 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2815,7 +2815,7 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2823,7 +2823,7 @@ async def test_scanner_count_connectable( async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 1d07ab75a48..8cffbe685b6 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -107,7 +107,6 @@ async def test_wrapped_bleak_client_local_adapter_only( return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d..b660be74aa9 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -228,7 +228,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -238,7 +238,7 @@ async def test_recovery_from_dbus_restart( # Fire a callback to reset the timer with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ): _callback( @@ -248,7 +248,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -258,7 +258,7 @@ async def test_recovery_from_dbus_restart( # We hit the timer, so we restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( @@ -303,7 +303,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -318,7 +318,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -328,7 +328,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -338,7 +338,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -392,7 +392,7 @@ async def test_adapter_scanner_fails_to_start_first_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -407,7 +407,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -417,7 +417,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -427,7 +427,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -443,7 +443,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -506,7 +506,7 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -557,7 +557,7 @@ async def test_restart_takes_longer_than_watchdog_time( "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -572,7 +572,7 @@ async def test_restart_takes_longer_than_watchdog_time( # Now force a recover adapter 2x for _ in range(2): with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), From ecc49e61f658ad2a1ed071d3606a1d9a5203130c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sun, 3 Dec 2023 14:05:30 -0600 Subject: [PATCH 094/927] Bump plexapi to 4.15.6 (#104949) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2..6dbd6118d7c 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.6", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/requirements_all.txt b/requirements_all.txt index 640c050e05f..c790b593a45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 608a53b46f6..dd48bada87c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 From 833805f9be329172bf2844a2cf3babbee4caa09a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Dec 2023 21:10:37 +0100 Subject: [PATCH 095/927] Add StreamLabsWater to strict typing (#104957) --- .strict-typing | 1 + .../streamlabswater/binary_sensor.py | 16 +++++++++----- .../components/streamlabswater/sensor.py | 22 +++++++++++-------- mypy.ini | 10 +++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index daf2baabf67..6180379f977 100644 --- a/.strict-typing +++ b/.strict-typing @@ -317,6 +317,7 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae..4a974077592 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,19 +41,19 @@ def setup_platform( class StreamlabsLocationData: """Track and query location data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the location data.""" self._location_id = location_id self._client = client self._is_away = None @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): + def update(self) -> None: """Query and store location data.""" location = self._client.get_location(self._location_id) self._is_away = location["homeAway"] == "away" - def is_away(self): + def is_away(self) -> bool | None: """Return whether away more is enabled.""" return self._is_away @@ -59,19 +61,21 @@ class StreamlabsLocationData: class StreamlabsAwayMode(BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): + def __init__( + self, location_name: str, streamlabs_location_data: StreamlabsLocationData + ) -> None: """Initialize the away mode device.""" self._location_name = location_name self._streamlabs_location_data = streamlabs_location_data self._is_away = None @property - def name(self): + def name(self) -> str: """Return the name for away mode.""" return f"{self._location_name} {NAME_AWAY_MODE}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return if away mode is on.""" return self._streamlabs_location_data.is_away() diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f..42e551c5c11 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant @@ -48,7 +50,7 @@ def setup_platform( class StreamlabsUsageData: """Track and query usage data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the usage data.""" self._location_id = location_id self._client = client @@ -57,22 +59,22 @@ class StreamlabsUsageData: self._this_year = None @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): + def update(self) -> None: """Query and store usage data.""" water_usage = self._client.get_water_usage_summary(self._location_id) self._today = round(water_usage["today"], 1) self._this_month = round(water_usage["thisMonth"], 1) self._this_year = round(water_usage["thisYear"], 1) - def get_daily_usage(self): + def get_daily_usage(self) -> float | None: """Return the day's usage.""" return self._today - def get_monthly_usage(self): + def get_monthly_usage(self) -> float | None: """Return the month's usage.""" return self._this_month - def get_yearly_usage(self): + def get_yearly_usage(self) -> float | None: """Return the year's usage.""" return self._this_year @@ -83,7 +85,9 @@ class StreamLabsDailyUsage(SensorEntity): _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__(self, location_name, streamlabs_usage_data): + def __init__( + self, location_name: str, streamlabs_usage_data: StreamlabsUsageData + ) -> None: """Initialize the daily water usage device.""" self._location_name = location_name self._streamlabs_usage_data = streamlabs_usage_data @@ -95,7 +99,7 @@ class StreamLabsDailyUsage(SensorEntity): return f"{self._location_name} {NAME_DAILY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @@ -113,7 +117,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -127,6 +131,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/mypy.ini b/mypy.ini index 83bc95a940b..2a3a5f0fb0f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2932,6 +2932,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true From 23cd66c54b0f55e0aba29cd8e3dd39666500cf82 Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Sun, 3 Dec 2023 16:13:26 -0500 Subject: [PATCH 096/927] Fix Lyric HVAC mode reset on temperature change (#104910) * Fix Lyric HVAC mode reset on temperature change * Reduce code duplication * Revert additional bugfix Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/lyric/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) From c1f68c37673525ce0d6f8c1e240cb84601d17303 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 12:18:10 -1000 Subject: [PATCH 097/927] Bump habluetooth to 0.4.0 (#104958) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/bluetooth/scanner.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 74cec5edfe2..a295e3f70ad 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0", - "habluetooth==0.1.0" + "habluetooth==0.4.0" ] } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 203bdac68a6..95733039df4 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -140,6 +140,7 @@ class HaScanner(BaseHaScanner): self._new_info_callback = new_info_callback self.scanning = False self.hass = hass + self._last_detection = 0.0 @property def discovered_devices(self) -> list[BLEDevice]: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7f67dcbf7a..ad55e397b72 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.1.0 +habluetooth==0.4.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index c790b593a45..38f8f3077eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.1.0 +habluetooth==0.4.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd48bada87c..077a07258b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.1.0 +habluetooth==0.4.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 From fe2906f15965d32dbfc65555b35d35633a20634c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 02:06:01 +0100 Subject: [PATCH 098/927] Do not allow smtp to access insecure files (#104972) --- homeassistant/components/smtp/notify.py | 30 ++++++++++++++++------- tests/components/smtp/test_notify.py | 32 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..02a5a6408b6 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -193,10 +194,15 @@ class MailNotificationService(BaseNotificationService): if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -241,13 +247,21 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id=""): +def _attach_file(hass, atch_name, content_id=""): """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. Otherwise add them as attachments. """ try: + file_path = Path(atch_name).parent + if not hass.config.is_allowed_path(str(file_path)): + _LOGGER.warning( + "'%s' is not secure to load data from, ignoring attachment '%s'!", + file_path, + atch_name, + ) + return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +291,22 @@ def _attach_file(atch_name, content_id=""): return attachment -def _build_multipart_msg(message, images): +def _build_multipart_msg(hass, message, images): """Build Multipart message with images as attachments.""" - _LOGGER.debug("Building multipart email with image attachment(s)") + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() body_txt = MIMEText(message) msg.attach(body_txt) for atch_name in images: - attachment = _attach_file(atch_name) + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -303,7 +317,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..06110a3e5dc 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -132,15 +133,44 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email): + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert "test.jpg' is not secure to load data from, ignoring attachment" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( From b56cd169ac9548be2d741288626d469723a23a25 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Dec 2023 07:46:36 +0100 Subject: [PATCH 099/927] Use constants in config flow scaffold (#104964) --- .../config_flow/integration/config_flow.py | 13 ++--- .../config_flow/tests/test_config_flow.py | 49 +++++++++---------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296..caef6c2e729 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378c..6ef59bf4337 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 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.""" @@ -24,22 +21,22 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", } assert len(mock_setup_entry.mock_calls) == 1 @@ -54,17 +51,17 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -77,14 +74,14 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} From 374b1cfd0c60ca83b222555e99795ab917fcc35e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Dec 2023 07:48:05 +0100 Subject: [PATCH 100/927] Fix bug in config flow scaffold (#104965) --- script/scaffold/templates/config_flow/tests/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 6ef59bf4337..f08f95e74fc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", From 453f91a3ae8e270e6a1024ede61e7a2fc7eb102c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 4 Dec 2023 17:09:15 +1000 Subject: [PATCH 101/927] Add virtual integration Fujitsu anywAIR (#102978) Add anywair --- homeassistant/components/fujitsu_anywair/__init__.py | 1 + homeassistant/components/fujitsu_anywair/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/fujitsu_anywair/__init__.py create mode 100644 homeassistant/components/fujitsu_anywair/manifest.json diff --git a/homeassistant/components/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000..5845e00f8b0 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000..463f0724919 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 636d1030850..d99487c2a60 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1941,6 +1941,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", From 401c8903646f9b1e00ba32b40c6a2cec8b735b1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 21:42:07 -1000 Subject: [PATCH 102/927] Bump habluetooth to 0.5.1 (#104969) * bump lib * bump again to be patchable --- 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 a295e3f70ad..71850ca1320 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0", - "habluetooth==0.4.0" + "habluetooth==0.5.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ad55e397b72..f7cc9199974 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.4.0 +habluetooth==0.5.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 38f8f3077eb..f43f0889e05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.4.0 +habluetooth==0.5.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 077a07258b0..e04cb291a59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.4.0 +habluetooth==0.5.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 6fd96f856d6c316192297026de20d452d24788ee Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 4 Dec 2023 07:48:47 +0000 Subject: [PATCH 103/927] Bump evohome-async to 0.4.13 (#104960) bump client to 0.4.13 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 769c8e597cd..e8b54eac38e 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.9"] + "requirements": ["evohome-async==0.4.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index f43f0889e05..4ad1157c547 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.9 +evohome-async==0.4.13 # homeassistant.components.faa_delays faadelays==2023.9.1 From 3b5e498c30c24c485c37c980694e7b9e619aa63a Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Mon, 4 Dec 2023 00:07:46 -0800 Subject: [PATCH 104/927] Bump screenlogicpy to v0.10.0 (#104866) --- .../components/screenlogic/climate.py | 37 +++++++++++-------- .../components/screenlogic/coordinator.py | 13 +++++-- homeassistant/components/screenlogic/data.py | 5 ++- .../components/screenlogic/entity.py | 14 +++++-- .../components/screenlogic/manifest.json | 2 +- .../components/screenlogic/number.py | 35 +++++------------- .../components/screenlogic/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 58 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498..d78c2c16e48 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -150,13 +150,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +169,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +187,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f49927171..f16f2b9ff34 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6..cda1bc83f81 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ ENTITY_MIGRATIONS = { "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3..253d16610e4 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -170,8 +174,10 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): await self._async_set_circuit(ON_OFF.OFF) async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700..434b8921bc2 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72b..091d377a56b 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,6 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN @@ -32,7 +34,6 @@ class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] @dataclass @@ -47,20 +48,12 @@ class ScreenLogicNumberDescription( SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -113,7 +106,6 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -138,21 +130,14 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) - # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self._set_value_func(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014..5d4efc55883 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -139,7 +139,7 @@ SUPPORTED_INTELLICHEM_SENSORS = [ ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, diff --git a/requirements_all.txt b/requirements_all.txt index 4ad1157c547..4798e238115 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2411,7 +2411,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e04cb291a59..f76644fbbd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,7 +1799,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 From 67039e0f2618031c2cd09c0233ef3fb5b2e72eb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 22:10:13 -1000 Subject: [PATCH 105/927] Remove monotonic_time_coarse datetime helper (#104892) --- homeassistant/util/dt.py | 29 ----------------------------- tests/util/test_dt.py | 6 ------ 2 files changed, 35 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d14..4859c5c85dd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,9 +5,7 @@ import bisect from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time from typing import Any import zoneinfo @@ -16,7 +14,6 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -476,29 +473,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400..a973135d831 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -737,8 +736,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 From 6335c245686f56748bb684a77b5ebeb63ed45b7a Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:27 +0100 Subject: [PATCH 106/927] Bump bimmer-connected to 0.14.6 (#104961) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 1ebf52e52ae..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.5"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4798e238115..5430b12f3d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -529,7 +529,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f76644fbbd6..a293bfb9f91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 7ae6343b250e1bdb12feca5d4e68f90193a37b0f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:48 +0100 Subject: [PATCH 107/927] Obihai to OpenGarage: add host field description (#104858) Co-authored-by: Jan Bouwhuis --- homeassistant/components/obihai/strings.json | 6 ++++++ homeassistant/components/octoprint/strings.json | 3 +++ homeassistant/components/onewire/strings.json | 3 +++ homeassistant/components/onvif/strings.json | 3 +++ homeassistant/components/opengarage/strings.json | 3 +++ 5 files changed, 18 insertions(+) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, From 91463566c06d2655ec6d87094c970bfbe5a5a9ab Mon Sep 17 00:00:00 2001 From: Jirka Date: Mon, 4 Dec 2023 09:14:24 +0100 Subject: [PATCH 108/927] Update balboa strings.json (#104977) --- homeassistant/components/balboa/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 101436c0f31..e0af12514da 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -7,7 +7,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." + "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } } }, From 9c9d8669ec5d92d6549b99bc41a4d513e9f295c3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 09:36:41 +0100 Subject: [PATCH 109/927] Link second Hue host field description (#104885) --- homeassistant/components/hue/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 122cb489d26..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -16,7 +16,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your Hue bridge." + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { From 8e42105b2dd8c17c9071ba6b3ca13190d35679dd Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Mon, 4 Dec 2023 09:45:59 +0100 Subject: [PATCH 110/927] Fix incompatible 'measurement' state and 'volume' device class warnings in Overkiz (#104896) --- homeassistant/components/overkiz/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..0bb9043c040 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, From 557e9337bccba5b8f7f49e3365205023842b09b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20H=C3=A4rtel?= <60009336+Haerteleric@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:48:44 +0100 Subject: [PATCH 111/927] Add CB3 descriptor to ZHA manifest (#104071) --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cd53772777a..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", From b0d0f15911ec4a73bd65d023f3b5eeab6fa24ae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 22:53:47 -1000 Subject: [PATCH 112/927] Bump dbus-fast to 2.20.0 (#104978) --- 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 71850ca1320..65d8b9cb892 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", - "dbus-fast==2.14.0", + "dbus-fast==2.20.0", "habluetooth==0.5.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7cc9199974..0ba9076f407 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.14.0 +dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5430b12f3d5..b005cdcc902 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a293bfb9f91..9911bdba599 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,7 +539,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 From 9b9d9c6116aba9f232707ad0916b1dbd07921cb4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:02 +0100 Subject: [PATCH 113/927] Reolink to Ruckus: add host field description (#104861) Co-authored-by: starkillerOG --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/rfxtrx/strings.json | 3 +++ homeassistant/components/roomba/strings.json | 6 ++++++ homeassistant/components/ruckus_unleashed/strings.json | 3 +++ 4 files changed, 15 insertions(+) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5b26d70b657..5a27f0e38cb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,9 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." } }, "reauth_confirm": { diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, From ab4c6cddf22cd8a375a5768a29b6025d7d0aa3b2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:51 +0100 Subject: [PATCH 114/927] Radio Thermostat to Renson: add host field description (#104860) --- homeassistant/components/radiotherm/strings.json | 3 +++ homeassistant/components/rainbird/strings.json | 3 +++ homeassistant/components/rainforest_eagle/strings.json | 3 +++ homeassistant/components/renson/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..8aa7c6244ea 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, From bf63674af27d255a93eb25e6c9d9141910c5fd64 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:38:52 +0100 Subject: [PATCH 115/927] Nanoleaf to Nut: add host field description (#104857) Co-authored-by: starkillerOG --- homeassistant/components/nanoleaf/strings.json | 3 +++ homeassistant/components/netgear/strings.json | 3 +++ homeassistant/components/nfandroidtv/strings.json | 3 +++ homeassistant/components/nuki/strings.json | 3 +++ homeassistant/components/nut/strings.json | 5 ++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index eb380cabd04..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]", "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { From 3316f6980d24359693624773ab344f7493def5f6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Dec 2023 10:44:29 +0100 Subject: [PATCH 116/927] Do not fail if Reolink ONVIF cannot be connected (#104947) --- homeassistant/components/reolink/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index fe53639822f..dfc77806932 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -165,7 +165,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() From 3cba10fa200ac93bb3dde8569e7c2d8bf269f016 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:44:52 +0100 Subject: [PATCH 117/927] Lifx, Lutron: add host field description (#104855) --- homeassistant/components/lifx/strings.json | 3 +++ homeassistant/components/lutron_caseta/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { From ff84b82027af20f30958c5c3b6fe369f9dee5476 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:45:43 +0100 Subject: [PATCH 118/927] Squeezebox to Synology DSM: add host field description (#104864) Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> Co-authored-by: Jan-Philipp Benecke Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> --- homeassistant/components/squeezebox/strings.json | 3 +++ homeassistant/components/switchbee/strings.json | 3 +++ homeassistant/components/synology_dsm/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b..756235ae247 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Media Server." } }, "edit": { diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e..858bda35c0f 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your SwitchBee device." } } }, diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238..4ed06119577 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Synology NAS." } }, "2sa": { From 7ac8f191bd933f40dc0dc5fec18c4d35151aeaa7 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:46:49 +0100 Subject: [PATCH 119/927] Modern Forms to MyStrom: add host field description (#104856) --- homeassistant/components/modern_forms/strings.json | 3 +++ homeassistant/components/moehlenhoff_alpha2/strings.json | 3 +++ homeassistant/components/mutesync/strings.json | 3 +++ homeassistant/components/mystrom/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, From 34d01719f25840ef413c2a74382d8537ec882910 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 10:47:49 +0100 Subject: [PATCH 120/927] Minor improvements of deprecation helper (#104980) --- homeassistant/helpers/deprecation.py | 44 +++++++++++++++++++--------- tests/util/yaml/test_init.py | 4 +-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..5a0682fdda2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -99,7 +99,11 @@ def get_deprecated( def deprecated_class( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,7 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning(cls, replacement, "class", "instantiated") return cls(*args, **kwargs) return deprecated_cls @@ -118,7 +122,11 @@ def deprecated_class( def deprecated_function( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +134,7 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning(func, replacement, "function", "called") return func(*args, **kwargs) return deprecated_func @@ -134,10 +142,23 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, +) -> None: logger = logging.getLogger(obj.__module__) try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s. Use %s instead", + obj.__name__, + description, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,10 +170,11 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, replacement, @@ -160,16 +182,10 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 3a2d9b3734d..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -636,8 +636,8 @@ async def test_deprecated_loaders( ): loader_class() assert ( - f"{loader_class.__name__} was called from hue, this is a deprecated class. " - f"Use {new_class} instead" + f"{loader_class.__name__} was instantiated from hue, this is a deprecated " + f"class. Use {new_class} instead" ) in caplog.text From bf49a3dcc29876185dc90476bb7c3be93e65ec0c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:48:58 +0100 Subject: [PATCH 121/927] Solar-Log to Soundtouch: add host field description (#104863) --- homeassistant/components/solarlog/strings.json | 3 +++ homeassistant/components/soma/strings.json | 6 ++++-- homeassistant/components/somfy_mylink/strings.json | 3 +++ homeassistant/components/soundtouch/strings.json | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { From 0f3cb9b1b63c2d8fabe59da813f04573c0865892 Mon Sep 17 00:00:00 2001 From: Matthias Dunda Date: Mon, 4 Dec 2023 10:53:59 +0100 Subject: [PATCH 122/927] Add telegram message timestamp to event data (#87493) --- homeassistant/components/telegram_bot/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7d150e95977..1d71e055e2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -991,6 +992,7 @@ class BaseTelegramBotEntity: event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args From d8a6d864c0f8a02d5d502d8511eab4d92c0e6a85 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 11:48:29 +0100 Subject: [PATCH 123/927] Raise on smtp notification if attachment is not allowed (#104981) * Raise smtp notification if attachment not allowed * Pass url as placeholder * Use variable in err message * Add allow_list as placeholder --- homeassistant/components/smtp/notify.py | 26 +++++++++++++++++----- homeassistant/components/smtp/strings.json | 5 +++++ tests/components/smtp/test_notify.py | 17 ++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 02a5a6408b6..dcc2f49db0f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -32,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -255,13 +256,26 @@ def _attach_file(hass, atch_name, content_id=""): """ try: file_path = Path(atch_name).parent - if not hass.config.is_allowed_path(str(file_path)): - _LOGGER.warning( - "'%s' is not secure to load data from, ignoring attachment '%s'!", - file_path, - atch_name, + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name} " + f"from directory '{file_path} which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, ) - return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..38dd81ac196 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 06110a3e5dc..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -111,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -156,7 +157,6 @@ def test_send_message( ) def test_sending_insecure_files_fails( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, message_data, data, content_type, @@ -165,10 +165,19 @@ def test_sending_insecure_files_fails( """Verify if we cannot send messages with insecure attachments.""" sample_email = "" message.hass = hass - with patch("email.utils.make_msgid", return_value=sample_email): + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: result, _ = message.send_message(message_data, data=data) assert content_type in result - assert "test.jpg' is not secure to load data from, ignoring attachment" + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" def test_send_text_message(hass: HomeAssistant, message) -> None: From db51a8e1f7797ae875c660d3a1b13ef93addd0ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 11:52:10 +0100 Subject: [PATCH 124/927] Allow passing breaks_in_ha_version to deprecation helper decorators (#104985) --- homeassistant/helpers/deprecation.py | 26 ++++++++---- homeassistant/util/yaml/loader.py | 4 +- tests/helpers/test_deprecation.py | 59 +++++++++++++++++++++------- tests/util/yaml/test_init.py | 2 +- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 5a0682fdda2..20dbacde480 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -97,7 +97,7 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark class as deprecated and provide a replacement class to be used instead. @@ -111,7 +111,9 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class", "instantiated") + _print_deprecation_warning( + cls, replacement, "class", "instantiated", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -120,7 +122,7 @@ def deprecated_class( def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark function as deprecated and provide a replacement to be used instead. @@ -134,7 +136,9 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function", "called") + _print_deprecation_warning( + func, replacement, "function", "called", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -147,15 +151,21 @@ def _print_deprecation_warning( replacement: str, description: str, verb: str, + breaks_in_ha_version: str | None, ) -> None: logger = logging.getLogger(obj.__module__) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() except MissingIntegrationFrame: logger.warning( - "%s is a deprecated %s. Use %s instead", + "%s is a deprecated %s%s. Use %s instead", obj.__name__, description, + breaks_in, replacement, ) else: @@ -170,22 +180,24 @@ def _print_deprecation_warning( ) logger.warning( ( - "%s was %s from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), obj.__name__, verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was %s from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s%s. Use %s instead", obj.__name__, verb, integration_frame.integration, description, + breaks_in, replacement, ) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..0d06ddfb757 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -137,7 +137,7 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") +@deprecated_class("FastSafeLoader", breaks_in_ha_version="2024.6") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" @@ -151,7 +151,7 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") +@deprecated_class("PythonSafeLoader", breaks_in_ha_version="2024.6") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293..46716263d5b 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -119,32 +119,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +190,24 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +216,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +242,8 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..a96d08933ee 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -637,7 +637,7 @@ async def test_deprecated_loaders( loader_class() assert ( f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" + f"class which will be removed in HA Core 2024.6. Use {new_class} instead" ) in caplog.text From 7222e2b2d6bf92896e03791f3d44a6a37fcd23fd Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:52:54 +0100 Subject: [PATCH 125/927] T-add host field description (#104871) --- homeassistant/components/tellduslive/strings.json | 4 +++- homeassistant/components/tesla_wall_connector/strings.json | 3 +++ homeassistant/components/tplink/strings.json | 3 +++ homeassistant/components/tplink_omada/strings.json | 4 +++- homeassistant/components/tradfri/strings.json | 3 +++ homeassistant/components/twinkly/strings.json | 3 +++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { From e8475b9b33eda194deacc9a8621015be9b0d9263 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 12:10:58 +0100 Subject: [PATCH 126/927] Add scaling utils for brightness and fanspeed (#104753) Co-authored-by: Robert Resch --- homeassistant/components/bond/fan.py | 2 +- homeassistant/components/comfoconnect/fan.py | 2 +- homeassistant/components/isy994/fan.py | 2 +- homeassistant/components/knx/fan.py | 2 +- homeassistant/components/modern_forms/fan.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- .../components/mqtt/light/schema_json.py | 19 +- homeassistant/components/renson/fan.py | 2 +- homeassistant/components/smartthings/fan.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/vesync/fan.py | 2 +- homeassistant/components/wemo/fan.py | 2 +- homeassistant/components/zha/fan.py | 2 +- homeassistant/util/color.py | 37 ++ homeassistant/util/percentage.py | 23 +- homeassistant/util/scaling.py | 62 +++ tests/util/snapshots/test_color.ambr | 519 ++++++++++++++++++ tests/util/test_color.py | 137 +++++ tests/util/test_scaling.py | 249 +++++++++ 19 files changed, 1034 insertions(+), 36 deletions(-) create mode 100644 homeassistant/util/scaling.py create mode 100644 tests/util/snapshots/test_color.ambr create mode 100644 tests/util/test_scaling.py diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 3cb81ba40b4..465c4b8966b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0..f76ed5939f5 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b4..ebdef4146e0 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65..a22a16a6e69 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c32235..e6bcff715b8 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e3dcf66c8b1..24783e171c8 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3d2957f153d..8702069eab7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,10 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - scale = self._config[CONF_BRIGHTNESS_SCALE] - self._attr_brightness = min( - 255, - round(brightness * 255 / scale), # type: ignore[operator] + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -591,13 +591,12 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6..6bceca92db0 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -13,10 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909..6c814b781b2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105..d3ba407fa40 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 22983054dc9..f0d4d02a9a3 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,10 +11,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196..39abdba6e82 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c6b9a104885..7364aed0d1b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -20,10 +20,10 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 4520a62a5d8..0ab4ac8c6c1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ from typing import NamedTuple import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -744,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670..cc4835022d3 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000..70e2ac2516a --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000..514502131fb --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_color.py b/tests/util/test_color.py index a7e6ba9ab46..5dd20d8d887 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -587,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000..5fef6cf806b --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val From 53becaa976a1caa1249e856ac0fa9da8e9b31e3b Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 4 Dec 2023 03:32:08 -0800 Subject: [PATCH 127/927] Bump opower==0.0.40 (#104986) --- 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 1022ab07e2c..d3a5928150e 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.39"] + "requirements": ["opower==0.0.40"] } diff --git a/requirements_all.txt b/requirements_all.txt index b005cdcc902..5a21b98bd8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1420,7 +1420,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9911bdba599..6b3638cc496 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1096,7 +1096,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 From 95f7db197039efa257b811efea2ea27a277477fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 12:48:49 +0100 Subject: [PATCH 128/927] Move config_per_platform and extract_domain_configs to config.py (#104989) --- homeassistant/components/automation/config.py | 4 +- .../components/device_tracker/legacy.py | 7 ++- .../components/homeassistant/scene.py | 8 +-- homeassistant/components/mailbox/__init__.py | 7 +-- homeassistant/components/notify/legacy.py | 3 +- homeassistant/components/script/config.py | 4 +- homeassistant/components/stt/legacy.py | 3 +- homeassistant/components/tts/legacy.py | 3 +- homeassistant/config.py | 45 ++++++++++++++--- homeassistant/helpers/__init__.py | 49 ++++++++++++------- homeassistant/helpers/entity_component.py | 4 +- homeassistant/helpers/reload.py | 3 +- tests/helpers/test_init.py | 16 +++++- tests/test_config.py | 33 +++++++++++++ 14 files changed, 138 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6d..ff0fe43ea26 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..5f2a3c3ba52 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_schema_error, load_yaml_config_file +from homeassistant.config import ( + async_log_schema_error, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 3308083f22f..9abfefc996f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -30,11 +30,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.service import ( async_extract_entity_ids, @@ -208,7 +204,7 @@ async def async_setup_platform( await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164..623d0f06295 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3..30981cd3658 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,10 +6,11 @@ from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294f..1cbab23d843 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ from homeassistant.components.blueprint import ( is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d..cd5aef312ce 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,8 +6,9 @@ from collections.abc import AsyncIterable, Coroutine import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d1..a52bcb802ab 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,7 +26,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/config.py b/homeassistant/config.py index b4850e372fd..5d5d246884c 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass from enum import StrEnum @@ -48,6 +48,7 @@ from .const import ( CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -58,12 +59,7 @@ from .const import ( from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES -from .helpers import ( - config_per_platform, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound @@ -1222,6 +1218,41 @@ def async_handle_component_errors( ) +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None + + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + pattern = re.compile(rf"^{domain}(| .+)$") + return [key for key in config if pattern.match(key)] + + async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712..52197e83495 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config - if not isinstance(platform_config, list): - platform_config = [platform_config] + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - yield platform, item + +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 775d0934c36..30e892a8840 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -148,7 +148,7 @@ class EntityComponent(Generic[_EntityT]): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 42ebc2d0869..983b4e2da52 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -69,7 +68,7 @@ async def _resetup_platform( root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, platform_domain): + for p_type, p_config in conf_util.config_per_platform(conf, platform_domain): if p_type != integration_domain: continue diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc..39b387000ca 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) -def test_config_per_platform() -> None: + +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/test_config.py b/tests/test_config.py index de5e7e0581d..1e309e2908f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2207,3 +2207,36 @@ async def test_yaml_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) From 8661aa96bdea25deaa57d76bd7ab846bf16cacb4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:10:22 +0100 Subject: [PATCH 129/927] U-V add host field description (#104872) Co-authored-by: Simone Chemelli --- homeassistant/components/unifi/strings.json | 3 +++ homeassistant/components/unifiprotect/strings.json | 3 +++ homeassistant/components/v2c/strings.json | 3 +++ homeassistant/components/vallox/strings.json | 3 +++ homeassistant/components/venstar/strings.json | 5 ++++- homeassistant/components/vilfo/strings.json | 3 +++ homeassistant/components/vizio/strings.json | 4 +++- homeassistant/components/vlc_telnet/strings.json | 3 +++ homeassistant/components/vodafone_station/strings.json | 3 +++ homeassistant/components/volumio/strings.json | 3 +++ 10 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a0cf3aae03a..dafdd597e77 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address fo your V2C Trydan EVSE." } } }, diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { From 157c4e31df1b9a0aa069b51cd9474aa9dc44ad32 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Dec 2023 13:10:51 +0100 Subject: [PATCH 130/927] Update frontend to 20231204.0 (#104990) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b6668383b54..e254eda0689 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231130.0"] + "requirements": ["home-assistant-frontend==20231204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ba9076f407..3bfbdc9acd1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.5.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5a21b98bd8f..0d62effff6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b3638cc496..b0a05507e13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 1629bdcd7ffe57bc9b0734ba5ec45ccb17be6586 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 4 Dec 2023 14:48:40 +0100 Subject: [PATCH 131/927] Remove "swap: none" from modbus (#104713) --- homeassistant/components/modbus/__init__.py | 6 +++--- homeassistant/components/modbus/base_platform.py | 3 --- homeassistant/components/modbus/const.py | 1 - homeassistant/components/modbus/validators.py | 6 ++---- tests/components/modbus/test_sensor.py | 6 ------ 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee..46bb5b83731 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -100,7 +100,6 @@ from .const import ( # noqa: F401 CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -179,9 +178,10 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e..1458abc0f25 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -55,7 +55,6 @@ from .const import ( CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, @@ -158,8 +157,6 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): """Initialize the switch.""" super().__init__(hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc97..745793e4057 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,7 +45,6 @@ CONF_STEP = "temp_step" CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 52919a24ac7..eaf787b3010 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -30,7 +30,6 @@ from .const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -115,8 +114,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) - swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + swap_type = config.get(CONF_SWAP) for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), @@ -136,9 +135,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: ) raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: + if swap_type: swap_type_validator = { - CONF_SWAP_NONE: validator.swap_byte, CONF_SWAP_BYTE: validator.swap_byte, CONF_SWAP_WORD: validator.swap_word, CONF_SWAP_WORD_BYTE: validator.swap_word, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index d0a4e23f780..bb093c24af0 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -19,7 +19,6 @@ from homeassistant.components.modbus.const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -125,7 +124,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +226,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -555,7 +552,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -1290,7 +1286,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1306,7 +1301,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], From 188d6a6eeeeb5f93f46ae2748afe605b9ea7aeff Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:48:56 +0100 Subject: [PATCH 132/927] W-Z: add host field description (#104996) --- homeassistant/components/weatherflow/strings.json | 4 +++- homeassistant/components/webostv/strings.json | 4 +++- homeassistant/components/wled/strings.json | 3 +++ homeassistant/components/yamaha_musiccast/strings.json | 3 +++ homeassistant/components/yardian/strings.json | 3 +++ homeassistant/components/yeelight/strings.json | 3 +++ homeassistant/components/youless/strings.json | 3 +++ homeassistant/components/zeversolar/strings.json | 3 +++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, From 13fdac23c15168f11052082ab220a63006f27197 Mon Sep 17 00:00:00 2001 From: Bartosz Dokurno Date: Mon, 4 Dec 2023 14:58:37 +0100 Subject: [PATCH 133/927] Update Todoist config flow URL (#104992) --- homeassistant/components/todoist/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { From 7d21ed41a2250bf4f8af22691579927acfb681f3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 14:59:51 +0100 Subject: [PATCH 134/927] Refactor lock default code handling (#104807) --- homeassistant/components/lock/__init__.py | 72 +++++++++--------- tests/components/lock/test_init.py | 91 ++++++++++------------- 2 files changed, 75 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ed7e2070055..ca91236a77c 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -33,7 +33,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) @@ -75,48 +74,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) return True -@callback -def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: - data = remove_entity_service_fields(service_call) - code: str = data.pop(ATTR_CODE, "") - if not code: - code = entity._lock_option_default_code # pylint: disable=protected-access - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - if code: - data[ATTR_CODE] = code - return data - - -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - await entity.async_lock(**_add_default_code(entity, service_call)) - - -async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: - """Unlock the lock.""" - await entity.async_unlock(**_add_default_code(entity, service_call)) - - -async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: - """Open the door latch.""" - await entity.async_open(**_add_default_code(entity, service_call)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -149,6 +121,21 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + raise ValueError( + f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}" + ) + if code: + data[ATTR_CODE] = code + return data + @property def changed_by(self) -> str | None: """Last change triggered by.""" @@ -193,6 +180,11 @@ class LockEntity(Entity): """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -201,6 +193,11 @@ class LockEntity(Entity): """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -209,6 +206,11 @@ class LockEntity(Entity): """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 16f40fda786..637acc22d05 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -9,10 +9,6 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, - DOMAIN, - SERVICE_LOCK, - SERVICE_OPEN, - SERVICE_UNLOCK, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, @@ -20,11 +16,8 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -87,7 +80,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_locking assert lock.state == STATE_LOCKING - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() assert lock.is_locked assert lock.state == STATE_LOCKED @@ -95,7 +88,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_unlocking assert lock.state == STATE_UNLOCKING - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() assert not lock.is_locked assert lock.state == STATE_UNLOCKED @@ -189,12 +182,12 @@ async def test_lock_open_with_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) + await lock.async_handle_open_service(code="HELLO") + await lock.async_handle_open_service(code="1234") assert lock.calls_open.call_count == 1 @@ -203,16 +196,16 @@ async def test_lock_lock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="HELLO") + await lock.async_handle_lock_service(code="1234") assert lock.is_locked @@ -221,18 +214,16 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="1234") assert lock.is_locked with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) - ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="HELLO") + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked @@ -245,17 +236,11 @@ async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: lock.hass = hass with pytest.raises(ValueError): - await _async_open( - lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_open_service(code="123456") with pytest.raises(ValueError): - await _async_lock( - lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_lock_service(code="123456") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_unlock_service(code="123456") async def test_lock_with_no_code(hass: HomeAssistant) -> None: @@ -265,18 +250,18 @@ async def test_lock_with_no_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({}) @@ -292,18 +277,18 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} assert lock._lock_option_default_code == "1234" - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) @@ -316,11 +301,11 @@ async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + await lock.async_handle_open_service(code="4321") lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_lock_service(code="4321") lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_unlock_service(code="4321") lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) @@ -337,8 +322,8 @@ async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "123456" with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() From 516966db332a843f257f727453d677efc14f6b27 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 4 Dec 2023 17:21:41 +0100 Subject: [PATCH 135/927] Add Matter custom cluster sensors (Eve Energy Plug energy measurements) (#104830) * Support for sensors from custom clusters in Matter * lint * no need to write state twice * Add test for eve energy plug * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * adjust comment * debounce extra poll timer * use async_call_later helper * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * wip extend test * Update test_sensor.py * fix state class for sensors * trigger (fake) event callback on all subscribers * Update eve-energy-plug.json * add test for additionally polled value * adjust delay to 3 seconds * Adjust subscribe_events to always use kwargs * Update tests/components/matter/common.py Co-authored-by: Martin Hjelmare * Update test_sensor.py * remove redundant code --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 9 +- homeassistant/components/matter/discovery.py | 5 +- homeassistant/components/matter/entity.py | 42 +- homeassistant/components/matter/models.py | 6 + homeassistant/components/matter/sensor.py | 74 +- tests/components/matter/common.py | 8 +- .../fixtures/nodes/eve-energy-plug.json | 649 ++++++++++++++++++ tests/components/matter/test_adapter.py | 7 +- tests/components/matter/test_sensor.py | 82 ++- 9 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-energy-plug.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -106,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d8f892f992..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -208,3 +221,70 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" From 35e2f591c194c81695d809437d3b9a9e5c3ece2d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 20:36:16 +0100 Subject: [PATCH 136/927] Make `cv.string` return subclasses of str as is (#103916) --- homeassistant/helpers/config_validation.py | 7 ++++++- tests/helpers/test_config_validation.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba0789..e07596ad450 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -581,7 +582,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6d1945f2d5f..b44137e4f5c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), From 677c50a7cc58e9caf24cd5ab117adeb9900431d3 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Mon, 4 Dec 2023 11:37:09 -0800 Subject: [PATCH 137/927] Exclude Todoist sub-tasks for the todo platform (#104914) --- homeassistant/components/todoist/todo.py | 3 +++ tests/components/todoist/conftest.py | 3 ++- tests/components/todoist/test_todo.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 64e83b8cc6e..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -85,6 +85,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4e4d41b6914..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -46,6 +46,7 @@ def make_api_task( due: Due | None = None, project_id: str | None = None, description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -61,7 +62,7 @@ def make_api_task( id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index aa00e2c2ff4..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -51,6 +51,14 @@ def set_time_zone(hass: HomeAssistant) -> None: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( From a9381d259040d4a02881cc9e203bc8e3946b2146 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Dec 2023 14:13:15 -0600 Subject: [PATCH 138/927] Add Wyoming satellite (#104759) * First draft of Wyoming satellite * Set up homeassistant in tests * Move satellite * Add devices with binary sensor and select * Add more events * Add satellite enabled switch * Fix mistake * Only set up necessary platforms for satellites * Lots of fixes * Add tests * Use config entry id as satellite id * Initial satellite test * Add satellite pipeline test * More tests * More satellite tests * Only support single device per config entry * Address comments * Make a copy of platforms --- homeassistant/components/wyoming/__init__.py | 77 ++- .../components/wyoming/binary_sensor.py | 55 +++ .../components/wyoming/config_flow.py | 91 +++- homeassistant/components/wyoming/data.py | 39 +- homeassistant/components/wyoming/devices.py | 85 ++++ homeassistant/components/wyoming/entity.py | 24 + .../components/wyoming/manifest.json | 4 +- homeassistant/components/wyoming/models.py | 13 + homeassistant/components/wyoming/satellite.py | 380 +++++++++++++++ homeassistant/components/wyoming/select.py | 47 ++ homeassistant/components/wyoming/strings.json | 30 +- homeassistant/components/wyoming/stt.py | 5 +- homeassistant/components/wyoming/switch.py | 65 +++ homeassistant/components/wyoming/tts.py | 5 +- homeassistant/components/wyoming/wake_word.py | 5 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 28 +- tests/components/wyoming/conftest.py | 47 +- .../wyoming/snapshots/test_config_flow.ambr | 42 ++ .../components/wyoming/test_binary_sensor.py | 34 ++ tests/components/wyoming/test_config_flow.py | 81 ++- tests/components/wyoming/test_data.py | 43 +- tests/components/wyoming/test_devices.py | 78 +++ tests/components/wyoming/test_satellite.py | 460 ++++++++++++++++++ tests/components/wyoming/test_select.py | 83 ++++ tests/components/wyoming/test_switch.py | 32 ++ 28 files changed, 1802 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/wyoming/binary_sensor.py create mode 100644 homeassistant/components/wyoming/devices.py create mode 100644 homeassistant/components/wyoming/entity.py create mode 100644 homeassistant/components/wyoming/models.py create mode 100644 homeassistant/components/wyoming/satellite.py create mode 100644 homeassistant/components/wyoming/select.py create mode 100644 homeassistant/components/wyoming/switch.py create mode 100644 tests/components/wyoming/test_binary_sensor.py create mode 100644 tests/components/wyoming/test_devices.py create mode 100644 tests/components/wyoming/test_satellite.py create mode 100644 tests/components/wyoming/test_select.py create mode 100644 tests/components/wyoming/test_switch.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..2cc9b7050a0 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +34,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..90dad889707 --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,85 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..540aaa9aeac 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.3.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..caf65db115e --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,380 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.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=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..2929ae79fa0 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,47 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..19b6a513d4b 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,31 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression": { + "name": "Noise suppression" + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e8d117d1f33..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -715,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/requirements_all.txt b/requirements_all.txt index 0d62effff6c..76860344a5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2760,7 +2760,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0a05507e13..9d1cd7befce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2064,7 +2064,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..899eda7ec1a 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,13 @@ """Tests for the Wyoming integration.""" import asyncio +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -72,24 +74,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,7 +119,7 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..a30c1048eb6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,23 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +119,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..27294186a90 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + satellite_device.set_is_active(False) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..06ae337a19c --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,460 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..cab699336fb --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,83 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # async_pipeline_changed should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..0b05724d761 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,32 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled From 84e74e4c7409fc48b322db05b772732ddf4391e2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 08:43:58 +0100 Subject: [PATCH 139/927] Reverse component path (#104087) * Reverse component path * Update translations helper * Fix * Revert incorrect change of PLATFORM_FORMAT * Fix use of PLATFORM_FORMAT in tts * Fix ios --- .../components/device_tracker/legacy.py | 2 +- homeassistant/components/ios/notify.py | 4 ++-- homeassistant/components/notify/legacy.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/translation.py | 10 +++++----- homeassistant/setup.py | 4 ++-- .../device_tracker/test_config_entry.py | 2 +- tests/components/device_tracker/test_init.py | 4 ++-- tests/components/tts/test_init.py | 2 +- tests/components/weather/test_init.py | 2 +- tests/helpers/test_entity_component.py | 10 +++++----- tests/helpers/test_entity_platform.py | 12 ++++++------ tests/helpers/test_reload.py | 8 ++++---- tests/helpers/test_restore_state.py | 2 +- tests/helpers/test_translation.py | 16 ++++++++-------- tests/test_setup.py | 6 +++--- 17 files changed, 45 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5f2a3c3ba52..264926a65bf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -287,7 +287,7 @@ class DeviceTrackerPlatform: ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1..de6091e3638 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 30981cd3658..93b6833edc6 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -126,7 +126,7 @@ def async_setup_legacy( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875..9a44382e851 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ class SpeechManager: self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2fc82567739..be087241287 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -304,7 +304,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878..d6a31085cfb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -48,7 +48,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +57,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -96,7 +96,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +154,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +225,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 679042bc4e9..53e88f2aaa5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -538,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -563,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2..49912fd282f 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def test_connected_device_registered( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c5..2960789c646 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +603,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11..5be56edbc32 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 3890d6a28d1..b982ab610ec 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1286,7 +1286,7 @@ async def test_issue_forecast_deprecated_no_logging( assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 40e25633992..60d0774b549 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -317,7 +317,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components async def test_setup_entry(hass: HomeAssistant) -> None: @@ -680,7 +680,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 721114c1a7b..dfaec4577aa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -268,7 +268,7 @@ async def test_platform_error_slow_setup( await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -833,7 +833,7 @@ async def test_setup_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1669,7 +1669,7 @@ async def test_setup_entry_with_entities_that_block_forever( ): assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 586dbc19eb8..4425ce00ce1 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -53,7 +53,7 @@ async def test_reload_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -93,7 +93,7 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -134,7 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -186,7 +186,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f01718d6af6..d69996e5d29 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -508,7 +508,7 @@ async def test_restore_entity_end_to_end( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b4253218..62152299932 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -255,7 +255,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +263,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +276,13 @@ async def test_translation_merging( assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +308,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +323,7 @@ async def test_translation_merging_loaded_apart( assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/test_setup.py b/tests/test_setup.py index 00bb3fa2a2d..14c56d39a5a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -672,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -729,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] From e80ee09f5e6bab3beb9b1189d4bda5a3028bf746 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:50:32 +0100 Subject: [PATCH 140/927] Make UniFi WiFi clients numerical (#105032) --- homeassistant/components/unifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..eaa7cc6fe08 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -231,6 +231,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, From 482e087a851da922a7c73d8bd1119569f107c796 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:55:15 +0100 Subject: [PATCH 141/927] Make unifi RX-/TX-sensors diagnostic entities (#105022) --- homeassistant/components/unifi/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index eaa7cc6fe08..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", From b7bc49b86366ee58971ba1756f354ebc37ee1e28 Mon Sep 17 00:00:00 2001 From: Marco <24938492+Marco98@users.noreply.github.com> Date: Tue, 5 Dec 2023 09:21:03 +0100 Subject: [PATCH 142/927] Fix Mikrotik rename from wifiwave2 to wifi for upcoming RouterOS 7.13 (#104966) Co-authored-by: Marco98 --- homeassistant/components/mikrotik/const.py | 4 ++++ homeassistant/components/mikrotik/hub.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices From c2cc8014dc5c8a117ed991c3bac85b4c0731dd0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Dec 2023 22:29:43 -1000 Subject: [PATCH 143/927] Avoid double URL creation for hassio ingress (#105052) --- homeassistant/components/hassio/ingress.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 751e9005809..0c0fe55b686 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -67,18 +67,20 @@ class HassIOIngress(HomeAssistantView): self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -128,7 +130,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( From 9b53fa6478481fb9014bdfcc6f29f193967fb5ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Dec 2023 22:30:21 -1000 Subject: [PATCH 144/927] Bump habluetooth to 0.6.1 (#105029) --- 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 65d8b9cb892..24c1202a2fe 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.5.1" + "habluetooth==0.6.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3bfbdc9acd1..e9055eddebd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.5.1 +habluetooth==0.6.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 76860344a5b..26b1f98f5a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.5.1 +habluetooth==0.6.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d1cd7befce..1b1e923b330 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.5.1 +habluetooth==0.6.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 4b87936779b050df12da88dca175d439d8c91c37 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 09:42:43 +0100 Subject: [PATCH 145/927] Fix stuck clients in UniFi options (#105028) --- homeassistant/components/unifi/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, From 5cab64bfcd7fc45396d7625f5c7d5cf187ac9c40 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 09:48:46 +0100 Subject: [PATCH 146/927] Make season types translatable (#105027) --- .../components/season/config_flow.py | 19 ++++++++++++++----- homeassistant/components/season/strings.json | 8 ++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10..069037e53a0 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd412..b0313d227a3 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } From ae002e2f3892dd2b6ed6c76b0721624a38285d2e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 10:48:31 +0100 Subject: [PATCH 147/927] Remove breaks_in_ha_version from deprecated YAML classes (#105062) --- homeassistant/util/yaml/loader.py | 4 ++-- tests/util/yaml/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 0d06ddfb757..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -137,7 +137,7 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader", breaks_in_ha_version="2024.6") +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" @@ -151,7 +151,7 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader", breaks_in_ha_version="2024.6") +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a96d08933ee..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -637,7 +637,7 @@ async def test_deprecated_loaders( loader_class() assert ( f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class which will be removed in HA Core 2024.6. Use {new_class} instead" + f"class. Use {new_class} instead" ) in caplog.text From 5b59e043fa0aea8819ccb91c2f41f460c7ca4185 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 11:36:26 +0100 Subject: [PATCH 148/927] Don't use deprecated_class decorator on deprecated YAML classes (#105063) --- homeassistant/util/yaml/loader.py | 60 ++++++++++++++++++++++++++++--- tests/util/yaml/test_init.py | 15 ++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..4a14afb53b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,7 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,10 +137,36 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -151,10 +177,36 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/.homeassistant/custom_components/hue/light.py", + filename="/home/paulus/homeassistant/components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "new_class"), + ("loader_class", "message"), [ - (yaml.loader.SafeLoader, "FastSafeLoader"), + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), ( yaml.loader.SafeLineLoader, - "PythonSafeLoader", + "'SafeLineLoader' instead of 'PythonSafeLoader'", ), ], ) @@ -628,17 +628,14 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - new_class: str, + message: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert ( - f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" - ) in caplog.text + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text def test_string_annotated(try_both_loaders) -> None: From 0638088aee66f0db31373d557c25b188997c4a50 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 5 Dec 2023 13:08:33 +0100 Subject: [PATCH 149/927] Deprecate and remove lazy_error from modbus (#105037) --- .../components/modbus/base_platform.py | 47 ++++++++++++++----- .../components/modbus/binary_sensor.py | 17 +++---- homeassistant/components/modbus/climate.py | 11 ++--- homeassistant/components/modbus/cover.py | 10 ++-- homeassistant/components/modbus/fan.py | 2 +- homeassistant/components/modbus/light.py | 2 +- homeassistant/components/modbus/sensor.py | 16 ++----- homeassistant/components/modbus/strings.json | 6 ++- homeassistant/components/modbus/switch.py | 2 +- tests/components/modbus/test_binary_sensor.py | 45 +----------------- tests/components/modbus/test_climate.py | 45 +----------------- tests/components/modbus/test_cover.py | 45 +----------------- tests/components/modbus/test_fan.py | 3 -- tests/components/modbus/test_light.py | 2 - tests/components/modbus/test_sensor.py | 41 +--------------- tests/components/modbus/test_switch.py | 45 +----------------- 16 files changed, 66 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 1458abc0f25..1c7c8f65140 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -61,6 +62,7 @@ from .const import ( CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -74,8 +76,34 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -92,8 +120,6 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -153,9 +179,9 @@ class BasePlatform(Entity): class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] @@ -247,10 +273,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -343,15 +369,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae8931..6c0f6422df2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070..5de08803cd4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -67,7 +67,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +79,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -288,15 +289,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc18..072f1bb3d93 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b..e5006b66f81 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21..acc01f39b46 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6..c015d117b13 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df596..c549b59bf8f 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,13 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb84096006..0c955ea409d 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a892dd205fb..e47a6165b30 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74..4b4ba00b4c6 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,5 +1,4 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -22,7 +21,6 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +75,6 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -581,46 +578,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f70..39897822bc8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a..0922329d4b7 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ from homeassistant.components.modbus.const import ( CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12..ecd9abd71b8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index bb093c24af0..8fb7f9fd951 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -49,7 +47,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -80,7 +78,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -97,7 +94,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -1142,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c082..28c44440581 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], From f460fdf63227631c7eada8c764eb73cf86895e7f Mon Sep 17 00:00:00 2001 From: Thomas Zahari Date: Tue, 5 Dec 2023 13:15:16 +0100 Subject: [PATCH 150/927] Add fields cancelled & extra to result of the departure HVV sensor (#105030) --- homeassistant/components/hvv_departures/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a8efb663c90..b30a9b375b0 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction" ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity): departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity): for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures From 6e0ba8e726100e7c28890b854eb9c2fdbfc71af3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Dec 2023 13:40:14 +0100 Subject: [PATCH 151/927] Improve matrix typing (#105067) --- homeassistant/components/matrix/__init__.py | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index ddda50aa8b2..44a65a2de59 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__) SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ class MatrixBot: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { From c4fbc78c057bda72d922e6fd6425819b80294d46 Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Tue, 5 Dec 2023 13:03:39 +0000 Subject: [PATCH 152/927] Fix geniushub smart plug state at start-up (#102110) * Smart plug did state wrong at start-up * Update docstring to reflect code --- homeassistant/components/geniushub/switch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. From db9d6b401a0898aabca9c5fd21ff381a6c3f41ef Mon Sep 17 00:00:00 2001 From: dupondje Date: Tue, 5 Dec 2023 14:28:57 +0100 Subject: [PATCH 153/927] Add optional dsmr timestamp sensor (#104979) * Add optional timestamp sensor * Apply suggestions from code review Remove "timestamp" translation Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/dsmr/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 722b8eda326..6aadcd63d44 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -78,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", translation_key="current_electricity_usage", From 25bea91683f416c026f24deb48498890937e04de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 15:06:13 +0100 Subject: [PATCH 154/927] Use modern platform path when reporting platform config errors (#104238) * Use modern platform path when reporting platform config errors * Update tests * Address review comment * Explicitly pass platform domain to log helpers * Revert overly complicated changes * Try a simpler solution --- homeassistant/config.py | 40 +++++++---- homeassistant/helpers/check_config.py | 8 ++- homeassistant/setup.py | 2 +- tests/components/rest/test_switch.py | 9 ++- tests/components/template/test_cover.py | 2 +- tests/components/trend/test_binary_sensor.py | 4 +- tests/helpers/test_check_config.py | 9 ++- tests/scripts/test_check_config.py | 5 +- tests/snapshots/test_config.ambr | 70 ++++++++++---------- 9 files changed, 89 insertions(+), 60 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 5d5d246884c..bbdd30c3683 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -141,7 +141,7 @@ class ConfigExceptionInfo: exception: Exception translation_key: ConfigErrorTranslationKey - platform_name: str + platform_path: str config: ConfigType integration_link: str | None @@ -659,7 +659,14 @@ def stringify_invalid( - Give a more user friendly output for unknown options - Give a more user friendly output for missing options """ - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: message_suffix = f", please check the docs at {link}" else: @@ -730,7 +737,14 @@ def format_homeassistant_error( link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" # HomeAssistantError raised by custom config validator has no path to the # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): @@ -1064,7 +1078,7 @@ def _get_log_message_and_stack_print_pref( ) -> tuple[str | None, bool, dict[str, str]]: """Get message to log and print stack trace preference.""" exception = platform_exception.exception - platform_name = platform_exception.platform_name + platform_path = platform_exception.platform_path platform_config = platform_exception.config link = platform_exception.integration_link @@ -1088,7 +1102,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( - f"Unknown error validating {platform_name} platform config with {domain} " + f"Unknown error validating {platform_path} platform config with {domain} " "component platform schema", True, ), @@ -1101,7 +1115,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( - f"Unknown error validating config for {platform_name} platform " + f"Unknown error validating config for {platform_path} platform " f"for {domain} component with PLATFORM_SCHEMA", True, ), @@ -1115,7 +1129,7 @@ def _get_log_message_and_stack_print_pref( show_stack_trace = False if isinstance(exception, vol.Invalid): log_message = format_schema_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): placeholders["config_file"], line = annotation @@ -1124,9 +1138,9 @@ def _get_log_message_and_stack_print_pref( if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) - if annotation := find_annotation(platform_config, [platform_name]): + if annotation := find_annotation(platform_config, [platform_path]): placeholders["config_file"], line = annotation placeholders["line"] = str(line) show_stack_trace = True @@ -1363,7 +1377,7 @@ async def async_process_component_config( # noqa: C901 platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema - platform_name = f"{domain}.{p_name}" + platform_path = f"{p_name}.{domain}" try: p_validated = component_platform_schema(p_config) except vol.Invalid as exc: @@ -1400,7 +1414,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1413,7 +1427,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1428,7 +1442,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, - platform_name, + platform_path, p_config, p_integration.documentation, ) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 23707949dcd..59334c20b30 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -276,13 +276,17 @@ async def async_check_ha_config_file( # noqa: C901 # show errors for a missing integration in recovery mode or safe mode to # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 53e88f2aaa5..7a7f4323be6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -263,7 +263,7 @@ async def _async_setup_component( if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: continue async_notify_setup_error( - hass, platform_exception.platform_name, platform_exception.integration_link + hass, platform_exception.platform_path, platform_exception.integration_link ) if processed_config is None: log_error("Invalid config.") diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index df90af44e73..7ded4fb0aed 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -62,8 +62,8 @@ async def test_setup_missing_config( await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) assert ( - "Invalid config for 'switch.rest': required key 'resource' not provided" - in caplog.text + "Invalid config for 'switch' from integration 'rest': required key 'resource' " + "not provided" in caplog.text ) @@ -75,7 +75,10 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for 'switch.rest': invalid url" in caplog.text + assert ( + "Invalid config for 'switch' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 35f03ee9508..88f0fc366a3 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover.template'" in caplog_setup_text + assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index ddd980ae970..1906c002101 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -513,6 +513,6 @@ async def test_invalid_min_sample( record = caplog.records[0] assert record.levelname == "ERROR" assert ( - "Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples" - in record.message + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message ) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b65f09aeaf9..de57fa0a8f3 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -227,7 +227,12 @@ async def test_platform_not_found(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -361,7 +366,7 @@ async def test_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e0869..425ad561f50 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 7438bda5cde..26a44f60184 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -7,18 +7,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -63,18 +63,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -119,18 +119,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -143,18 +143,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -167,18 +167,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -255,18 +255,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -274,12 +274,12 @@ # name: test_component_config_validation_error_with_docs[basic] list([ "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 ''', "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", From 3bcc6194efd5b10866506b37ead68f393b5a61a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 15:07:32 +0100 Subject: [PATCH 155/927] Add domain key config validation (#104242) * Drop use of regex in helpers.extract_domain_configs * Update test * Revert test update * Add domain_from_config_key helper * Add validator * Address review comment * Update snapshots * Inline domain_from_config_key in validator --- homeassistant/bootstrap.py | 5 +- homeassistant/config.py | 32 ++++++++++-- homeassistant/helpers/check_config.py | 3 +- homeassistant/helpers/config_validation.py | 24 +++++++++ .../basic/configuration.yaml | 5 ++ .../basic_include/configuration.yaml | 3 ++ .../basic_include/integrations/.yaml | 0 .../basic_include/integrations/5.yaml | 0 .../integrations/iot_domain .yaml | 0 .../include_dir_list/invalid_domains/.yaml | 0 .../include_dir_list/invalid_domains/5.yaml | 0 .../invalid_domains/iot_domain .yaml | 0 .../packages/configuration.yaml | 7 +++ .../integrations/pack_5.yaml | 1 + .../integrations/pack_empty.yaml | 1 + .../integrations/pack_iot_domain_space.yaml | 1 + tests/helpers/test_config_validation.py | 16 ++++++ tests/snapshots/test_config.ambr | 51 +++++++++++++++++++ tests/test_config.py | 4 +- 19 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0998ac6274c..83b2f18719f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .const import ( from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/config.py b/homeassistant/config.py index bbdd30c3683..6dd8bc21471 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -449,6 +449,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -982,9 +995,13 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( @@ -1263,8 +1280,13 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs async def async_process_component_config( # noqa: C901 diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 59334c20b30..1c8efadfdc5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,6 +31,7 @@ from homeassistant.requirements import ( ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901 core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e07596ad450..e4b62dd679d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -351,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any( ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 9c3d1eb190b..49db89f45ba 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -56,3 +56,8 @@ custom_validator_bad_1: # This always raises ValueError custom_validator_bad_2: + +# Invalid domains +"iot_domain ": +"": +5: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index 5744e3005fa..8e1c75c3511 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -8,3 +8,6 @@ custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index b8116b5988e..25d734b126a 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -68,3 +68,10 @@ homeassistant: pack_custom_validator_bad_2: # This always raises ValueError custom_validator_bad_2: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000..70bf80a6b64 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000..510d4682445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000..49b5720a536 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b44137e4f5c..f997e3a6c10 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1631,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 26a44f60184..76d3f0c4666 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,6 +1,18 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", @@ -57,6 +69,18 @@ # --- # name: test_component_config_validation_error[basic_include] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", @@ -161,6 +185,18 @@ # --- # name: test_component_config_validation_error[packages] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", @@ -217,6 +253,18 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", @@ -273,6 +321,9 @@ # --- # name: test_component_config_validation_error_with_docs[basic] list([ + "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", diff --git a/tests/test_config.py b/tests/test_config.py index 1e309e2908f..8ec509cd895 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2043,7 +2043,7 @@ async def test_component_config_validation_error( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, From 651df6b6987368816f1fd03b9d7e41d7d9fbf540 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 5 Dec 2023 10:51:51 -0500 Subject: [PATCH 156/927] Add calendar entity to Radarr (#79077) * Add calendar entity to Radarr * address feedback/add tests * black * uno mas * rework to coordinator * uno mas * move release atttribute writing * fix calendar items and attributes --- homeassistant/components/radarr/__init__.py | 4 +- homeassistant/components/radarr/calendar.py | 63 ++++++++++ .../components/radarr/coordinator.py | 105 ++++++++++++++++- tests/components/radarr/__init__.py | 18 +++ .../components/radarr/fixtures/calendar.json | 111 ++++++++++++++++++ tests/components/radarr/test_binary_sensor.py | 3 + tests/components/radarr/test_calendar.py | 41 +++++++ tests/components/radarr/test_config_flow.py | 2 + tests/components/radarr/test_init.py | 4 + tests/components/radarr/test_sensor.py | 2 + 10 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/radarr/calendar.py create mode 100644 tests/components/radarr/fixtures/calendar.json create mode 100644 tests/components/radarr/test_calendar.py diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: From a8ca73a7dd033de0cc290e21675af168e4365c7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Dec 2023 17:13:29 +0100 Subject: [PATCH 157/927] Finish scaffold config flow with either abort or create entry (#105012) --- .../config_flow/tests/test_config_flow.py | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index f08f95e74fc..bb9e6380cdc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -41,7 +41,9 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +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} @@ -63,8 +65,36 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() -async def test_form_cannot_connect(hass: HomeAssistant) -> None: + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + 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} @@ -85,3 +115,30 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 From 5b55c7da5fbaeffd3aa61f32dcb40a21ba192b6c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 18:08:11 +0100 Subject: [PATCH 158/927] Remove logic converting empty or falsy YAML to empty dict (#103912) * Correct logic converting empty YAML to empty dict * Modify according to github comments * Add load_yaml_dict helper * Update check_config script * Update tests --- homeassistant/components/blueprint/models.py | 3 +- .../components/lovelace/dashboard.py | 6 ++- homeassistant/components/notify/legacy.py | 6 +-- .../components/python_script/__init__.py | 4 +- homeassistant/components/tts/legacy.py | 8 ++-- homeassistant/config.py | 10 ++--- homeassistant/helpers/service.py | 6 ++- homeassistant/scripts/check_config.py | 2 +- homeassistant/util/yaml/__init__.py | 11 ++++- homeassistant/util/yaml/loader.py | 40 ++++++++++++++----- script/hassfest/services.py | 6 +-- tests/components/automation/test_init.py | 2 +- tests/components/lovelace/test_cast.py | 2 +- tests/components/lovelace/test_dashboard.py | 8 ++-- tests/components/lovelace/test_resources.py | 2 +- .../components/lovelace/test_system_health.py | 2 +- tests/components/samsungtv/test_trigger.py | 2 +- tests/components/webostv/test_trigger.py | 2 +- tests/components/zwave_js/test_trigger.py | 4 +- tests/util/yaml/test_init.py | 33 +++++++++++++++ 20 files changed, 112 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index ddf57aa6eee..63a1c1b45f0 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -215,7 +215,7 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -225,7 +225,6 @@ class DomainBlueprints: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e1641451221..d935ad9bff5 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 93b6833edc6..7c78bfc44d3 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -280,8 +280,8 @@ class BaseNotificationService: # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06..098603b9494 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def discover_scripts(hass): # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index a52bcb802ab..05be2e284e3 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -31,7 +31,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -104,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( diff --git a/homeassistant/config.py b/homeassistant/config.py index 6dd8bc21471..95dd42737a0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -66,7 +66,7 @@ from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system -from .util.yaml import SECRET_YAML, Secrets, load_yaml +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -476,15 +476,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7..2ada25bd4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ from homeassistant.exceptions import ( UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -542,7 +542,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0e00d0b75f2..dcccdbccf40 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43..fe4f01677cd 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ __all__ = [ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 4a14afb53b2..60e917a6a99 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -36,6 +36,10 @@ _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -211,7 +215,7 @@ class SafeLineLoader(PythonSafeLoader): LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: +def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -221,6 +225,20 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -255,12 +273,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -309,7 +322,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -339,7 +355,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -364,9 +383,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 4a826f7cad9..580294705cf 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d..359303c51fd 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1102,7 +1102,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a..4181d73c4d3 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b8..a772b37f047 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6..4a280eccfda 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605d..72e7adb3a13 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51..12af639b251 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd5..74573e2185b 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4e..26b9459cfc2 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ async def test_zwave_js_value_updated( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ async def test_zwave_js_event( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..1e31d8c6955 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -134,6 +134,7 @@ def test_include_yaml( [ ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) def test_include_dir_list( @@ -190,6 +191,10 @@ def test_include_dir_list_recursive( {"/test/first.yaml": "1", "/test/second.yaml": "2"}, {"first": 1, "second": 2}, ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), ], ) def test_include_dir_named( @@ -249,6 +254,10 @@ def test_include_dir_named_recursive( {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, [1, 2, 3], ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), ], ) def test_include_dir_merge_list( @@ -311,6 +320,13 @@ def test_include_dir_merge_list_recursive( }, {"key1": 1, "key2": 2, "key3": 3}, ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) def test_include_dir_merge_named( @@ -686,3 +702,20 @@ def test_string_used_as_vol_schema(try_both_loaders) -> None: schema({"key_1": "value_1", "key_2": "value_2"}) with pytest.raises(vol.Invalid): schema({"key_1": "value_2", "key_2": "value_1"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE) From 428c184c75a94ef91f9d9c222194b40c5371da90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 18:17:56 +0100 Subject: [PATCH 159/927] Improve yamaha tests (#105077) --- tests/components/yamaha/test_media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba..6fc3259a4c0 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone From b6245c834d5f9c51db4a984f3e5a5f6b6c66e0c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 07:19:02 -1000 Subject: [PATCH 160/927] Move local bluetooth scanner code into habluetooth library (#104970) --- .../components/bluetooth/__init__.py | 14 +- homeassistant/components/bluetooth/api.py | 8 +- homeassistant/components/bluetooth/models.py | 7 - .../bluetooth/passive_update_processor.py | 8 +- homeassistant/components/bluetooth/scanner.py | 390 ------------------ .../bluetooth/update_coordinator.py | 4 +- homeassistant/components/bluetooth/util.py | 9 - tests/components/bluetooth/conftest.py | 10 +- .../components/bluetooth/test_diagnostics.py | 223 +++++----- tests/components/bluetooth/test_init.py | 26 +- tests/components/bluetooth/test_scanner.py | 69 ++-- tests/conftest.py | 4 +- 12 files changed, 180 insertions(+), 592 deletions(-) delete mode 100644 homeassistant/components/bluetooth/scanner.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index a0eb263757a..99bb02054e7 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,7 +21,12 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) -from habluetooth import HaBluetoothConnector +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -76,10 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import MONOTONIC_TIME, BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -281,7 +285,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..897402d4049 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,6 +9,7 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import BluetoothScanningMode from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -17,12 +18,7 @@ from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER from .manager import BluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 48ba021cd6c..a35c5be6daf 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -17,13 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - - PASSIVE = "passive" - ACTIVE = "active" - - BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..8da0d2c462b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 95733039df4..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,390 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME -from dbus_fast import InvalidMessageError - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - self.hass = hass - self._last_detection = 0.0 - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - return self._unsetup - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index f276b6b51e5..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,7 +2,6 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 8625283266e..a69c26a16ea 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -497,17 +478,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +497,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -596,19 +572,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "connectable": False, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +630,17 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "storage": None, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index b24bb97e1e3..63ff735ca43 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner import pytest from homeassistant.components import bluetooth @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index b660be74aa9..c33bfd6db84 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -30,6 +29,14 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +49,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +64,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +84,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +104,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +125,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +147,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +167,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +215,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -306,7 +305,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -343,7 +342,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -395,7 +394,7 @@ async def test_adapter_scanner_fails_to_start_first_time( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -432,7 +431,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -448,7 +447,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +502,16 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,17 +553,15 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 @@ -617,7 +614,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/conftest.py b/tests/conftest.py index fcd8e8b73a9..4d0e2565164 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1574,14 +1574,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start From dc17780e5baab14f5cd5f6a6ad9ab54102042fc8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 18:52:22 +0100 Subject: [PATCH 161/927] Remove device from known_devices upon import in ping device tracker (#105009) Co-authored-by: Joost Lekkerkerker --- .../components/device_tracker/legacy.py | 13 +++ .../components/ping/device_tracker.py | 100 +++++++++++++----- .../components/device_tracker/test_legacy.py | 44 ++++++++ tests/components/ping/test_device_tracker.py | 41 ++++++- 4 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 tests/components/device_tracker/test_legacy.py diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 264926a65bf..a17972526cf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -1036,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ceff1b2e124..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -11,9 +12,20 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -42,34 +54,66 @@ async def async_setup_scanner( ) -> bool: """Legacy init: import via config flow.""" - for dev_name, dev_host in config[CONF_HOSTS].items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - }, - ) + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) return True diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index b6cc6b42912..5f5bb2132c1 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,13 +1,17 @@ """Test the binary sensor platform of ping.""" +from unittest.mock import patch import pytest +from homeassistant.components.device_tracker import legacy from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -56,7 +60,42 @@ async def test_import_issue_creation( ) await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 From dd37205a42577fe12b510399241c68d3ceb87c51 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Dec 2023 18:52:52 +0100 Subject: [PATCH 162/927] Update frontend to 20231205.0 (#105081) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e254eda0689..08eb0f0a424 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231204.0"] + "requirements": ["home-assistant-frontend==20231205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9055eddebd..b36fe975184 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.6.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 26b1f98f5a6..2653d1ac6fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b1e923b330..dd15a878212 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From d4cbe89c2fb969ea0d542e01ea9115af0a559e7a Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 5 Dec 2023 19:14:13 +0100 Subject: [PATCH 163/927] Update energyzero lib to v2.0.0 (#105080) --- 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 9ef99173ffb..7b1588eeb54 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==1.0.0"] + "requirements": ["energyzero==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2653d1ac6fb..45be2b74637 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.0.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd15a878212..1d0e94eaaee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -613,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.0.0 # homeassistant.components.enocean enocean==0.50 From 1edfaed7befd65d5ab7bc0e078423826d5ec047b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 19:14:35 +0100 Subject: [PATCH 164/927] Improve raise contains mocks (#105078) Co-authored-by: J. Nick Koston --- tests/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index b2fa53d28fb..15498019b16 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1427,7 +1427,7 @@ ANY = _HA_ANY() def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): From 3310f4c1304b25e049838673f20c13b3080b6c80 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 5 Dec 2023 19:17:52 +0100 Subject: [PATCH 165/927] Add significant Change support for weather (#104840) --- .../components/weather/significant_change.py | 175 +++++++++ .../weather/test_significant_change.py | 347 ++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 homeassistant/components/weather/significant_change.py create mode 100644 tests/components/weather/test_significant_change.py diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000..bd6571a390e --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,175 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import check_absolute_change + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not _check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not _check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/weather/test_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000..93e5830a0ac --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 94d168e20e3bf1b18b6d163a3b2df953c4c95a55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 08:53:29 -1000 Subject: [PATCH 166/927] Move Bluetooth advertisement tracker to habluetooth library (#105083) --- .../bluetooth/advertisement_tracker.py | 82 ------------------- homeassistant/components/bluetooth/manager.py | 5 +- .../bluetooth/test_advertisement_tracker.py | 4 +- .../components/bluetooth/test_base_scanner.py | 4 +- .../private_ble_device/test_device_tracker.py | 5 +- .../private_ble_device/test_sensor.py | 5 +- 6 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 homeassistant/components/bluetooth/advertisement_tracker.py diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f5..00000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e9f490285c9..9c3517982af 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -17,6 +17,7 @@ from bluetooth_adapters import ( BluetoothAdapters, ) from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -29,10 +30,6 @@ from homeassistant.core import ( from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 6ae847ba84a..8681287baa2 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +11,6 @@ from homeassistant.components.bluetooth import ( async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5886cc10aac..1228a4efc5b 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,6 +8,7 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth @@ -17,9 +18,6 @@ from homeassistant.components.bluetooth import ( HomeAssistantRemoteScanner, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b30738865..3834254ac7f 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d..e35643d7626 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( From 3c635fdbf2db3ce65737aa45406d2ad573ab9001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 08:54:50 -1000 Subject: [PATCH 167/927] Split bluetooth manager so it can be extracted into the habluetooth lib (#105015) --- .../components/bluetooth/__init__.py | 10 +- homeassistant/components/bluetooth/api.py | 6 +- homeassistant/components/bluetooth/manager.py | 348 ++++++++++-------- .../components/bluetooth/wrappers.py | 1 - 4 files changed, 195 insertions(+), 170 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 99bb02054e7..329b597d515 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -81,7 +81,7 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import MONOTONIC_TIME, BluetoothManager +from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage @@ -143,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -284,7 +286,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 897402d4049..afdd26a2001 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_ca from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper @@ -25,9 +25,9 @@ if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 9c3517982af..777d0ebe317 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta import itertools import logging from typing import TYPE_CHECKING, Any, Final @@ -28,7 +27,6 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( @@ -100,16 +98,12 @@ class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( - "hass", - "_integration_matcher", "_cancel_unavailable_tracking", - "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", - "_callback_index", "_bleak_callbacks", "_all_history", "_connectable_history", @@ -122,21 +116,17 @@ class BluetoothManager: "slot_manager", "_debug", "shutdown", + "_loop", ) def __init__( self, - hass: HomeAssistant, - integration_matcher: IntegrationMatcher, bluetooth_adapters: BluetoothAdapters, storage: BluetoothStorage, slot_manager: BleakSlotManager, ) -> None: """Init bluetooth manager.""" - self.hass = hass - self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None + self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals @@ -149,7 +139,6 @@ class BluetoothManager: str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} - self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] @@ -164,6 +153,7 @@ class BluetoothManager: self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False + self._loop: asyncio.AbstractEventLoop | None = None @property def supports_passive_scan(self) -> bool: @@ -206,7 +196,6 @@ class BluetoothManager: return adapter return None - @hass_callback def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: """Return the scanner for a source.""" return self._sources.get(source) @@ -229,45 +218,22 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) - @hass_callback - def _async_logging_changed(self, event: Event) -> None: - """Handle logging change.""" - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: """Set up the bluetooth manager.""" + self._loop = asyncio.get_running_loop() await self._bluetooth_adapters.refresh() install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) - self._async_trigger_matching_discovery(service_info) - @hass_callback - def async_stop(self, event: Event) -> None: + def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self.shutdown = True if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() - @hass_callback def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: @@ -288,7 +254,6 @@ class BluetoothManager: ) ] - @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """Return all of discovered addresses. @@ -304,24 +269,25 @@ class BluetoothManager: for scanner in self._non_connectable_scanners ) - @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" histories = self._connectable_history if connectable else self._all_history return [history.device for history in histories.values()] - @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", + self._schedule_unavailable_tracking() + + def _schedule_unavailable_tracking(self) -> None: + """Schedule the unavailable tracking.""" + if TYPE_CHECKING: + assert self._loop is not None + loop = self._loop + self._cancel_unavailable_tracking = loop.call_at( + loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable ) - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: + def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" monotonic_now = MONOTONIC_TIME() connectable_history = self._connectable_history @@ -363,8 +329,7 @@ class BluetoothManager: # available for both connectable and non-connectable tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) + self._address_disappeared(address) service_info = history.pop(address) @@ -377,13 +342,13 @@ class BluetoothManager: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) + self._schedule_unavailable_tracking() + + def _address_disappeared(self, address: str) -> None: + """Call when an address disappears from the stack. + + This method is intended to be overridden by subclasses. + """ def _prefer_previous_adv_from_different_source( self, @@ -436,7 +401,6 @@ class BluetoothManager: return False return True - @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. @@ -567,16 +531,6 @@ class BluetoothManager: time=service_info.time, ) - matched_domains = self._integration_matcher.match_domains(service_info) - if self._debug: - _LOGGER.debug( - "%s: %s %s match: %s", - self._async_describe_source(service_info), - address, - service_info.advertisement, - matched_domains, - ) - if (connectable or old_connectable_service_info) and ( bleak_callbacks := self._bleak_callbacks ): @@ -586,22 +540,14 @@ class BluetoothManager: for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - for match in self._callback_index.match_callbacks(service_info): - callback = match[CALLBACK] - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") + self._discover_service_info(service_info) - for domain in matched_domains: - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + """Discover a new service info. + + This method is intended to be overridden by subclasses. + """ - @hass_callback def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: """Describe a source.""" if scanner := self._sources.get(service_info.source): @@ -612,7 +558,6 @@ class BluetoothManager: description += " [connectable]" return description - @hass_callback def async_track_unavailable( self, callback: Callable[[BluetoothServiceInfoBleak], None], @@ -626,7 +571,6 @@ class BluetoothManager: unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) - @hass_callback def _async_remove_callback() -> None: unavailable_callbacks[address].remove(callback) if not unavailable_callbacks[address]: @@ -634,50 +578,6 @@ class BluetoothManager: return _async_remove_callback - @hass_callback - def async_register_callback( - self, - callback: BluetoothCallback, - matcher: BluetoothCallbackMatcher | None, - ) -> Callable[[], None]: - """Register a callback.""" - callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) - if not matcher: - callback_matcher[CONNECTABLE] = True - else: - # We could write out every item in the typed dict here - # but that would be a bit inefficient and verbose. - callback_matcher.update(matcher) - callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) - - connectable = callback_matcher[CONNECTABLE] - self._callback_index.add_callback_matcher(callback_matcher) - - @hass_callback - def _async_remove_callback() -> None: - self._callback_index.remove_callback_matcher(callback_matcher) - - # If we have history for the subscriber, we can trigger the callback - # immediately with the last packet so the subscriber can see the - # device. - history = self._connectable_history if connectable else self._all_history - service_infos: Iterable[BluetoothServiceInfoBleak] = [] - if address := callback_matcher.get(ADDRESS): - if service_info := history.get(address): - service_infos = [service_info] - else: - service_infos = history.values() - - for service_info in service_infos: - if ble_device_matches(callback_matcher, service_info): - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") - - return _async_remove_callback - - @hass_callback def async_ble_device_from_address( self, address: str, connectable: bool ) -> BLEDevice | None: @@ -687,13 +587,11 @@ class BluetoothManager: return history.device return None - @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" histories = self._connectable_history if connectable else self._all_history return address in histories - @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: @@ -701,7 +599,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.values() - @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: @@ -709,28 +606,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.get(address) - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) - - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) - def async_register_scanner( self, scanner: BaseHaScanner, @@ -758,7 +633,6 @@ class BluetoothManager: self.slot_manager.register_adapter(scanner.adapter, connection_slots) return _unregister_scanner - @hass_callback def async_register_bleak_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: @@ -766,7 +640,6 @@ class BluetoothManager: callback_entry = (callback, filters) self._bleak_callbacks.append(callback_entry) - @hass_callback def _remove_callback() -> None: self._bleak_callbacks.remove(callback_entry) @@ -780,29 +653,180 @@ class BluetoothManager: return _remove_callback - @hass_callback def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) - @hass_callback def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) - @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" return self._intervals.get(address) - @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: """Get the fallback availability timeout for a MAC address.""" return self._fallback_intervals.get(address) - @hass_callback def async_set_fallback_availability_interval( self, address: str, interval: float ) -> None: """Override the fallback availability timeout for a MAC address.""" self._fallback_intervals[address] = interval + + +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" + + __slots__ = ( + "hass", + "_integration_matcher", + "_callback_index", + "_cancel_logging_listener", + ) + + def __init__( + self, + hass: HomeAssistant, + integration_matcher: IntegrationMatcher, + bluetooth_adapters: BluetoothAdapters, + storage: BluetoothStorage, + slot_manager: BleakSlotManager, + ) -> None: + """Init bluetooth manager.""" + self.hass = hass + self._integration_matcher = integration_matcher + self._callback_index = BluetoothCallbackMatcherIndex() + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, storage, slot_manager) + + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) + + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + matched_domains = self._integration_matcher.match_domains(service_info) + if self._debug: + _LOGGER.debug( + "%s: %s %s match: %s", + self._async_describe_source(service_info), + service_info.address, + service_info.advertisement, + matched_domains, + ) + + for match in self._callback_index.match_callbacks(service_info): + callback = match[CALLBACK] + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + for domain in matched_domains: + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) + + def async_register_callback( + self, + callback: BluetoothCallback, + matcher: BluetoothCallbackMatcher | None, + ) -> Callable[[], None]: + """Register a callback.""" + callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) + if not matcher: + callback_matcher[CONNECTABLE] = True + else: + # We could write out every item in the typed dict here + # but that would be a bit inefficient and verbose. + callback_matcher.update(matcher) + callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) + + connectable = callback_matcher[CONNECTABLE] + self._callback_index.add_callback_matcher(callback_matcher) + + def _async_remove_callback() -> None: + self._callback_index.remove_callback_matcher(callback_matcher) + + # If we have history for the subscriber, we can trigger the callback + # immediately with the last packet so the subscriber can see the + # device. + history = self._connectable_history if connectable else self._all_history + service_infos: Iterable[BluetoothServiceInfoBleak] = [] + if address := callback_matcher.get(ADDRESS): + if service_info := history.get(address): + service_infos = [service_info] + else: + service_infos = history.values() + + for service_info in service_infos: + if ble_device_matches(callback_matcher, service_info): + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + return _async_remove_callback + + @hass_callback + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 9de020f163e..e3c08a035a8 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback ), timeout=self.__timeout, - hass=manager.hass, ) if debug_logging: # Only lookup the description if we are going to log it From be44de0a416f28554fe2be725f66cfe3c9d5d638 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Dec 2023 20:00:53 +0100 Subject: [PATCH 168/927] Fix overkiz measurement sensor returns None if 0 (#105090) --- homeassistant/components/overkiz/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 0bb9043c040..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -481,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function From eadcceeed15c420c03eca6ae25760d1046ea0012 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:00 -0800 Subject: [PATCH 169/927] Update apple_weatherkit to 1.1.1 (#105079) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a2ddde02ad4 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45be2b74637..50d275f5dc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d0e94eaaee..45822622d4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 From 19e193ae1dd8197e1c6775b70132b3e9fc9760b4 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:26 -0800 Subject: [PATCH 170/927] Increase frequency of weatherkit updates (#105094) --- homeassistant/components/weatherkit/coordinator.py | 2 +- tests/components/weatherkit/test_coordinator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850..824c85781ea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237a..7113e1d4d51 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() From 712a401ee23b0b4306fe79da16b0e44dafcf8fb5 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Tue, 5 Dec 2023 21:06:29 +0100 Subject: [PATCH 171/927] Bump renson library to version 1.7.1 (#105096) --- homeassistant/components/renson/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a946..fa94207748e 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50d275f5dc1..337764a1ddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2345,7 +2345,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45822622d4e..2096184ed73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1757,7 +1757,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.1 From 44810f9772eb89bf171022f42beeb17bea2408e4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 22:16:07 +0100 Subject: [PATCH 172/927] Bump aiounifi to v67 (#105099) * Bump aiounifi to v67 * Fix mypy --- homeassistant/components/unifi/controller.py | 4 ++-- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6bd8b9db426..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 52ed8ec3101..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==66"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 337764a1ddf..06db4496d2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2096184ed73..9c542e5718e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 636e38f4b390727ee2ca4c49c7106d0b5e132b7d Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Tue, 5 Dec 2023 13:24:41 -0800 Subject: [PATCH 173/927] Trigger Home Assistant shutdown automations right before the stop event instead of during it (#91165) Co-authored-by: Erik --- Dockerfile | 2 +- .../homeassistant/triggers/homeassistant.py | 33 +++--- homeassistant/core.py | 108 ++++++++++++++---- script/hassfest/docker.py | 7 +- tests/test_core.py | 54 +++++++++ tests/test_runner.py | 9 +- 6 files changed, 164 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 97eeb5b0dfa..43b21ab3ba8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ - S6_SERVICES_GRACETIME=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55..84aafb44808 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/core.py b/homeassistant/core.py index 7d9d8d19b49..7f0883ca880 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -107,9 +108,10 @@ if TYPE_CHECKING: from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() @@ -299,6 +301,14 @@ class HassJob(Generic[_P, _R_co]): return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -370,6 +380,7 @@ class HomeAssistant: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -766,6 +777,42 @@ class HomeAssistant: for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -799,6 +846,26 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -816,33 +883,32 @@ class HomeAssistant: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -856,12 +922,12 @@ class HomeAssistant: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -870,11 +936,11 @@ class HomeAssistant: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task ) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( - "Task %s error during stage 3 shutdown: %s", task, exc + "Task %s error during final shutdown stage: %s", task, exc ) # Prevent run_callback_threadsafe from scheduling any additional @@ -885,14 +951,14 @@ class HomeAssistant: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -912,10 +978,10 @@ class HomeAssistant: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 3bd44736038..c9d81424229 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -59,9 +59,10 @@ WORKDIR /config def _generate_dockerfile() -> str: timeout = ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7..d5b3ba5f87d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -399,6 +400,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -2566,3 +2593,30 @@ def test_hassjob_passing_job_type(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() diff --git a/tests/test_runner.py b/tests/test_runner.py index 3b06e3b64dc..14728321721 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ TIMEOUT_SAFETY_MARGIN = 10 async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN From ad26af608b5e7147844c9a9815cf3bfb8e042eb4 Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Tue, 5 Dec 2023 22:25:08 +0100 Subject: [PATCH 174/927] Fix typo in v2c strings.json (#105104) fo -> of --- homeassistant/components/v2c/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index dafdd597e77..bf19fe5188e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -6,7 +6,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address fo your V2C Trydan EVSE." + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, From 9a3b4939a9dbc4879d1b6fc025cd0c3ede9a1f18 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 6 Dec 2023 07:35:29 +0100 Subject: [PATCH 175/927] Update easyenergy lib to v2.0.0 (#105108) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6fa177c7221..0e57133a89a 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==1.0.0"] + "requirements": ["easyenergy==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 06db4496d2a..1f320b431a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -726,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.0.0 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c542e5718e..3872778c3f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -592,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.0.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From 2401a09600e877fcc85d04eb1c8796532df741ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:13:35 -1000 Subject: [PATCH 176/927] Bump aioesphomeapi to 19.3.0 (#105114) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7be54ad739f..e0b47f09d95 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.1", + "aioesphomeapi==19.3.0", "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1f320b431a0..f03ea9c458d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3872778c3f7..8b6533c5604 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 3fda43ef334b6431fd94bb7a84f6da5d9a8e995a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 6 Dec 2023 01:14:34 -0600 Subject: [PATCH 177/927] Bump intents to 2023.12.05 (#105116) --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2a069d5d92b..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b36fe975184..c18c921a3d0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231205.0 -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index f03ea9c458d..5719561af7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1027,7 +1027,7 @@ holidays==0.37 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b6533c5604..d164c1d5c47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -814,7 +814,7 @@ holidays==0.37 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 From a5281835566ac2f6003c664aee46c6a2107b8107 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:15:24 -1000 Subject: [PATCH 178/927] Bump habluetooth to 0.8.0 (#105109) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/__init__.py | 22 ++- .../components/bluetooth/test_base_scanner.py | 186 ++++-------------- tests/components/bluetooth/test_scanner.py | 81 ++++---- 7 files changed, 96 insertions(+), 201 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 24c1202a2fe..055eff43a91 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.6.1" + "habluetooth==0.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c18c921a3d0..10d041790a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.6.1 +habluetooth==0.8.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 5719561af7e..9fa892ca8c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.6.1 +habluetooth==0.8.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d164c1d5c47..c24e61c2204 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.6.1 +habluetooth==0.8.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c..5261e7371f3 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager from homeassistant.components.bluetooth import ( DOMAIN, @@ -19,8 +20,6 @@ from homeassistant.components.bluetooth import ( async_get_advertisement_callback, models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +36,7 @@ __all__ = ( "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +56,22 @@ BLE_DEVICE_DEFAULTS = { } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=mock_time, + ), patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 1228a4efc5b..2e2be0e7963 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -35,11 +35,35 @@ from . import ( _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(HomeAssistantRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -87,23 +111,6 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -171,23 +178,6 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -212,10 +202,7 @@ async def test_remote_scanner_expires_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -246,23 +233,6 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -295,10 +265,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -311,10 +278,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -344,23 +308,6 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -468,23 +415,6 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -512,11 +442,8 @@ async def test_device_with_ten_minute_advertising_interval( connectable=False, ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -525,11 +452,10 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -543,10 +469,7 @@ async def test_device_with_ten_minute_advertising_interval( bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -556,13 +479,7 @@ async def test_device_with_ten_minute_advertising_interval( future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -590,25 +507,6 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(HomeAssistantRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -626,10 +524,7 @@ async def test_scanner_stops_responding( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -650,11 +545,10 @@ async def test_scanner_stops_responding( failure_reached_time += 1 - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index c33bfd6db84..7673acb80dc 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -25,6 +25,7 @@ from . import ( async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -226,9 +227,8 @@ async def test_recovery_from_dbus_restart( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -236,9 +236,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # Fire a callback to reset the timer - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -246,9 +245,8 @@ async def test_recovery_from_dbus_restart( ) # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -256,9 +254,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -301,9 +298,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -316,9 +312,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -326,9 +321,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -336,9 +330,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -390,9 +383,8 @@ async def test_adapter_scanner_fails_to_start_first_time( scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -405,9 +397,8 @@ async def test_adapter_scanner_fails_to_start_first_time( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -415,9 +406,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -425,9 +415,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -441,9 +430,8 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -504,9 +492,8 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( with patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -555,9 +542,8 @@ async def test_restart_takes_longer_than_watchdog_time( with patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -568,9 +554,8 @@ async def test_restart_takes_longer_than_watchdog_time( # Now force a recover adapter 2x for _ in range(2): - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): From bf8f78c041abb7bbb12b738b5703071eec1b5bf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:15:48 -1000 Subject: [PATCH 179/927] Fix flakey logbook tests (#105111) --- tests/components/logbook/test_init.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d95b409a67b..671c70168d2 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -504,10 +505,7 @@ async def test_logbook_describe_event( ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -569,10 +567,7 @@ async def test_exclude_described_event( }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, From a904461b6a82dbcbd1b9e08c2f9ca79271bc82e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Dec 2023 08:28:02 +0100 Subject: [PATCH 180/927] Bump actions/setup-python from 4.7.1 to 4.8.0 (#105117) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 24 ++++++++++++------------ .github/workflows/translations.yml | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9d13c07301e..618a9a08d1f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9b9c8babb9..4da01579cbb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802..42d7ea1dd4f 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} From 88a4b74d4befead68ea11a7cf0723e2aaa4c1bf8 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 6 Dec 2023 09:32:10 +0000 Subject: [PATCH 181/927] bump evohome-async to 0.4.15 (#105119) --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index e8b54eac38e..062bba1cfdc 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.13"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fa892ca8c7..918289e627f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.13 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 From 7424c296b726afc55813c70cf57dbcc86c2becaf Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 6 Dec 2023 11:01:05 +0100 Subject: [PATCH 182/927] Add missing services and strings entries for reply_to_message_id (#105072) --- .../components/telegram_bot/services.yaml | 45 +++++++++++++++++- .../components/telegram_bot/strings.json | 46 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, From 81d05acd07bb260c5f3be7bdd9d66342229873eb Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 6 Dec 2023 12:39:46 +0100 Subject: [PATCH 183/927] Address late review for Holiday (#105121) --- homeassistant/components/holiday/config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 93ff2772eb8..1ba4a2a0c26 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -28,7 +28,9 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - data: dict[str, Any] = {} + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -37,7 +39,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self.data = user_input - selected_country = self.data[CONF_COUNTRY] + selected_country = user_input[CONF_COUNTRY] if SUPPORTED_COUNTRIES[selected_country]: return await self.async_step_province() @@ -46,7 +48,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): locale = Locale(self.hass.config.language) title = locale.territories[selected_country] - return self.async_create_entry(title=title, data=self.data) + return self.async_create_entry(title=title, data=user_input) user_schema = vol.Schema( { From a29695e622ee26d66693b06880fd74437536228e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 6 Dec 2023 14:23:26 +0200 Subject: [PATCH 184/927] Add Huawei LTE network mode select (#104614) * Convert network mode from sensor to select for huawei_lte This also introduces the select platform to huawei_lte integration. * Move (networkmode, str) mapping to const Also, rebase on top of the current dev * Fix variable naming, initialize name * Fix wrong key for router access * Typing fixes * Adapt to current way of registering subscriptions * Simplify option management, make translatable * Make use of custom entity description * Add icon * Revert sensor formatting changes, move to another PR * Improve entity class naming * Add test * Make sure entity descriptions define a setter function --------- Co-authored-by: Teemu Rytilahti --- .../components/huawei_lte/__init__.py | 1 + homeassistant/components/huawei_lte/select.py | 132 ++++++++++++++++++ .../components/huawei_lte/strings.json | 14 ++ tests/components/huawei_lte/test_select.py | 43 ++++++ 4 files changed, 190 insertions(+) create mode 100644 homeassistant/components/huawei_lte/select.py create mode 100644 tests/components/huawei_lte/test_select.py diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d8c939e5c3a..dcd40b8346c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -135,6 +135,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000..2f4b7274fc0 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,132 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +@dataclass +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + key: str + item: str + + _raw_state: str | None = field(default=None, init=False) + + def __post_init__(self) -> None: + """Initialize remaining attributes.""" + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 754f192e57e..225146799a3 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -286,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000..c3f6ded65b6 --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) From eb00259356c3d092e8587c65812b8c9db47891ed Mon Sep 17 00:00:00 2001 From: Xidorn Quan Date: Wed, 6 Dec 2023 23:30:31 +1100 Subject: [PATCH 185/927] Bump thermopro-ble to 0.5.0 (#105126) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 918289e627f..9b13ce0a6bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2601,7 +2601,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c24e61c2204..7ce8200d1e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 3f28354a00013b2a501ea82c2517c8dc54f6da2d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 14:39:27 +0100 Subject: [PATCH 186/927] Fix missing target in todo.remove_completed_items service (#105127) --- homeassistant/components/todo/services.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index bc7da7db941..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -86,3 +86,8 @@ remove_item: text: remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM From a16819e0e5ed5b806101aa7391f36b7ae45f300c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Dec 2023 14:43:26 +0100 Subject: [PATCH 187/927] Use freezegun in utility_meter tests (#105123) --- tests/components/utility_meter/test_init.py | 14 +++++++------- tests/components/utility_meter/test_sensor.py | 15 +++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253c..0ac8140c52d 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3..d77c2db356a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, From 297a7638ca13a3f1bd75d1737783c84fc6334cbb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 6 Dec 2023 14:51:36 +0100 Subject: [PATCH 188/927] Update frontend to 20231206.0 (#105132) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 08eb0f0a424..af2ea6f9149 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231205.0"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 10d041790a9..1673877b029 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.8.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9b13ce0a6bf..381ab657761 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ce8200d1e7..87c15ed0f81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 6721f9fdb2577a72fcec801fe96f1a39cec2a4e0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Dec 2023 16:21:12 +0100 Subject: [PATCH 189/927] Bump python-opensky to 1.0.0 (#105131) --- homeassistant/components/opensky/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adf..106103cf752 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 381ab657761..63fc084db6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2191,7 +2191,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87c15ed0f81..c9ea5df6ce7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1639,7 +1639,7 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread From c93abd9d202f1d0efe3b0da197e2ec07214ce742 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Dec 2023 16:22:32 +0100 Subject: [PATCH 190/927] Improve decorator type annotations [zwave_js] (#104825) * Improve decorator type annotations [zwave_js] * Improve _async_get_entry annotation --- homeassistant/components/zwave_js/api.py | 53 +++++++++++++++----- homeassistant/components/zwave_js/helpers.py | 4 +- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 9e50b55830c..7f4855bfbe5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ from .helpers import ( get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7..65c77f8ab2d 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema] +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: From ff21c02cb6373fe662c086579ffd659dff9bc94d Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Wed, 6 Dec 2023 09:53:52 -0700 Subject: [PATCH 191/927] Add preset modes to ESPHome fan entities (#103781) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/fan.py | 17 +++++++++++++++++ tests/components/esphome/test_fan.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 9942498e12d..08135e1a702 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9..6f383dcb6ba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] From 32febcda5a81af205ad66582e1740ed1d7ea2fcc Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Wed, 6 Dec 2023 09:36:46 -0800 Subject: [PATCH 192/927] Bump apple_weatherkit to 1.1.2 (#105140) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index a2ddde02ad4..a6dd40d5993 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.1.1"] + "requirements": ["apple_weatherkit==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63fc084db6a..564836737e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.1.1 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9ea5df6ce7..5d4bb6ac9b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.1.1 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 From 05e122e22b32113e39ae8f8e2b3e405ba796b7e2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Dec 2023 18:46:35 +0100 Subject: [PATCH 193/927] Modernize and cleanup trend tests (#105010) Co-authored-by: Em --- tests/components/trend/test_binary_sensor.py | 615 +++++++------------ 1 file changed, 223 insertions(+), 392 deletions(-) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 1906c002101..b525c7a8fa3 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,398 +1,247 @@ """The test for the Trend sensor platform.""" from datetime import timedelta import logging +from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, +from tests.common import assert_setup_component, get_fixture_path, mock_restore_cache + + +async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": params, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], ) +async def test_basic_trend( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +): + """Test trend with a basic setup.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "invert": inverted, + }, + ) + + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state -class TestTrendBinarySensor: - """Test the Trend sensor.""" +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +): + """Test uptrend using multiple samples and trendline calculation.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) - hass = None + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + attr_values: list[str], + expected_state: str, +): + """Test attribute uptrend.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples(hass: HomeAssistant): + """Test that sample count is limited correctly.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric(hass: HomeAssistant): + """Test for non-numeric sensor.""" + await _setup_component(hass, {"entity_id": "sensor.test_state"}) + + hass.states.async_set("sensor.test_state", "Non") + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "Numeric") + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_missing_attribute(hass: HomeAssistant): + """Test for missing attribute.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "missing", + }, + ) + + hass.states.async_set("sensor.test_state", "State", {"attr": "2"}) + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "State", {"attr": "1"}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_invalid_name_does_not_create(hass: HomeAssistant): + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} + "test INVALID sensor": {"entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, +async def test_invalid_sensor_does_not_create(hass: HomeAssistant): + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - now = dt_util.utcnow() - # add not enough states to trigger calculation - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert ( - self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown" +async def test_no_sensors_does_not_create(hass: HomeAssistant): + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} ) - - # add one more state to trigger gradient calculation - for val in [100]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on" - - # add more states to trigger a downtrend - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] + assert hass.states.async_all("binary_sensor") == [] async def test_reload(hass: HomeAssistant) -> None: @@ -436,79 +285,61 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } - }, - } + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state - now = dt_util.utcnow() - # add not enough samples to trigger calculation for val in [10, 20, 30, 40]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # state should match restored state as no calculation happened assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state # add more samples to trigger calculation for val in [50, 60, 70, 80]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # sensor should detect an upwards trend and turn on assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" async def test_invalid_min_sample( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if error is logged when min_sample is larger than max_samples.""" with caplog.at_level(logging.ERROR): - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 25, - "min_samples": 30, - } - }, - } + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, }, ) - await hass.async_block_till_done() record = caplog.records[0] assert record.levelname == "ERROR" From 16f6a579247807e6133025a9353c951b01d007ea Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Thu, 7 Dec 2023 07:12:27 +0100 Subject: [PATCH 194/927] Fix missing apostrophe in smtp (#105189) Fix missing apostrophe --- homeassistant/components/smtp/notify.py | 4 ++-- homeassistant/components/smtp/strings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index dcc2f49db0f..87600650551 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -263,8 +263,8 @@ def _attach_file(hass, atch_name, content_id=""): file_name = os.path.basename(atch_name) url = "https://www.home-assistant.io/docs/configuration/basic/" raise ServiceValidationError( - f"Cannot send email with attachment '{file_name} " - f"from directory '{file_path} which is not secure to load data from. " + f"Cannot send email with attachment '{file_name}' " + f"from directory '{file_path}' which is not secure to load data from. " f"Only folders added to `{allow_list}` are accessible. " f"See {url} for more information.", translation_domain=DOMAIN, diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 38dd81ac196..e8347ef1a89 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -7,7 +7,7 @@ }, "exceptions": { "remote_path_not_allowed": { - "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + "message": "Cannot send email with attachment '{file_name}' form directory '{file_path}' which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." } } } From 9181933619c5e8f28a355056593bffc6428413c2 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 7 Dec 2023 07:15:31 +0100 Subject: [PATCH 195/927] Fix ZHA quirk ID custom entities matching all devices (#105184) --- homeassistant/components/zha/core/registries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4bdedebfff9..87f59f31e9b 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -253,7 +253,7 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_ids and quirk_id: + if self.quirk_ids: if callable(self.quirk_ids): matches.append(self.quirk_ids(quirk_id)) else: From 4666d7e17f0db13c3529b54d1ee8f400abed4efe Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 7 Dec 2023 14:30:27 +0800 Subject: [PATCH 196/927] Bump yolink-api to 0.3.4 (#105124) * Bump yolink-api to 0.3.3 * bump yolink api to 0.3.4 --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7322c58ae04..a42687a3551 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.1"] + "requirements": ["yolink-api==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 564836737e9..efd8d4e6c81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2802,7 +2802,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d4bb6ac9b1..24de015fd25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2100,7 +2100,7 @@ yalexs==1.10.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 From 0b6665ed09ae4b9af3f0664cb286dc00699478a7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 7 Dec 2023 07:39:37 +0100 Subject: [PATCH 197/927] Bump reolink_aio to 0.8.2 (#105157) --- 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 5ffbc2fb186..e03fa28b7ce 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.8.1"] + "requirements": ["reolink-aio==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index efd8d4e6c81..c18b222251f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24de015fd25..bdba3f3629d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,7 +1760,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.rflink rflink==0.0.65 From 22119a2fd86658cf272032651e5701e90fdad6dd Mon Sep 17 00:00:00 2001 From: Sebastian Nohn Date: Thu, 7 Dec 2023 07:44:19 +0100 Subject: [PATCH 198/927] Set ping interval to 15 seconds instead of 5 minutes (#105191) set ping interval to a more sane value of 15 seconds instead of 5 minutes. fixes home-assistant/core#105163 --- homeassistant/components/ping/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index dadd105b606..5fe9d692bc3 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(minutes=5), + update_interval=timedelta(seconds=15), ) async def _async_update_data(self) -> PingResult: From e051244927dfae578d4c1bb00de726f121dff3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=ADan=20Hughes?= Date: Thu, 7 Dec 2023 07:19:03 +0000 Subject: [PATCH 199/927] Add Modbus fan speed support (#104577) Co-authored-by: jan iversen --- homeassistant/components/modbus/__init__.py | 33 +++ homeassistant/components/modbus/climate.py | 87 ++++++- homeassistant/components/modbus/const.py | 14 +- homeassistant/components/modbus/validators.py | 58 ++++- tests/components/modbus/test_climate.py | 220 +++++++++++++++++- tests/components/modbus/test_init.py | 191 ++++++++++++++- 6 files changed, 587 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 46bb5b83731..74a1de48c0a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -122,6 +134,7 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -265,6 +278,26 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5de08803cd4..76132014413 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ import struct from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -138,6 +160,42 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -194,6 +252,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -255,7 +328,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -269,7 +342,17 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 745793e4057..e536a31c4f6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -49,8 +49,19 @@ CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -59,6 +70,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index eaf787b3010..7dc5a91a2fa 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,12 +26,16 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -264,12 +268,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -280,7 +303,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -299,11 +322,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -313,3 +336,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 4b4ba00b4c6..325b68869e0 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -3,14 +3,34 @@ import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -183,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -193,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -338,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -529,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -545,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9..df415807119 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( From f420dcbedc6a11de4c89fdbe6dfcdc8e003e34ed Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Dec 2023 09:18:34 +0100 Subject: [PATCH 200/927] =?UTF-8?q?Bump=20M=C3=A9t=C3=A9o-France=20to=201.?= =?UTF-8?q?3.0=20(#105170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 3b6bb9c3518..567788ec479 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.2.0"] + "requirements": ["meteofrance-api==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c18b222251f..702da75f928 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1244,7 +1244,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdba3f3629d..425cd2cb382 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 From 2a8a60b3815639c377ccf821741315d23024f081 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 7 Dec 2023 09:19:38 +0100 Subject: [PATCH 201/927] Improve LIDL christmas light detection in deCONZ (#105155) --- homeassistant/components/deconz/light.py | 6 +++--- tests/components/deconz/test_light.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index dc2ed04b4ed..044c9bf203b 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } -TS0601_EFFECTS = [ +XMAS_LIGHT_EFFECTS = [ "carnival", "collide", "fading", @@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] - if device.model_id == "TS0601": - self._attr_effect_list += TS0601_EFFECTS + if device.model_id in ("HG06467", "TS0601"): + self._attr_effect_list = XMAS_LIGHT_EFFECTS @property def color_mode(self) -> str | None: diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 357371e4853..d38c65526c2 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -186,7 +186,6 @@ async def test_no_lights_or_groups( "state": STATE_ON, "attributes": { ATTR_EFFECT_LIST: [ - EFFECT_COLORLOOP, "carnival", "collide", "fading", From ebde8ccfe436e32bac07c336ad3c942c97415b60 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 02:22:03 -0600 Subject: [PATCH 202/927] Expose todo entities to Assist by default (#105150) --- homeassistant/components/homeassistant/exposed_entities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 16a7ee5009c..926ab5025f6 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = { "scene", "script", "switch", + "todo", "vacuum", "water_heater", } From 3d5329755f2ff47f3eb1e7c5a489794e62780c57 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:24:27 +0100 Subject: [PATCH 203/927] Add extra tests for HomeWizard for unsupported entity creation (#105149) --- tests/components/homewizard/test_number.py | 4 ++-- tests/components/homewizard/test_switch.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ebd8d80ece2..08154614833 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -97,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: - """Does not load button when device has no support for it.""" + """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 2f6e777a3a8..61ca34fab7a 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ pytestmark = [ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ From 316776742f83290299827064b6c36223161d60c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:27:48 +0100 Subject: [PATCH 204/927] Bump actions/setup-python from 4.8.0 to 5.0.0 (#105193) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 24 ++++++++++++------------ .github/workflows/translations.yml | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 618a9a08d1f..7c3a42aaaa1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4da01579cbb..2255b3f145c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 42d7ea1dd4f..c8e25cc83ea 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} From e40f486968e75031b6ae5c36fe178e6e4fb4ae7e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 7 Dec 2023 09:33:33 +0100 Subject: [PATCH 205/927] Disable scenarios (scenes) for local API in Overkiz (#105153) --- homeassistant/components/overkiz/__init__.py | 20 ++++++++----------- homeassistant/components/overkiz/strings.json | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ebc3f96a7f5..03a81f67308 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,10 +1,8 @@ """The Overkiz (by Somfy) integration.""" from __future__ import annotations -import asyncio from collections import defaultdict from dataclasses import dataclass -from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient @@ -16,7 +14,7 @@ from pyoverkiz.exceptions import ( NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry @@ -82,13 +80,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.login() + setup = await client.get_setup() - setup, scenarios = await asyncio.gather( - *[ - client.get_setup(), - client.get_scenarios(), - ] - ) + # Local API does expose scenarios, but they are not functional. + # Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21 + if api_type == APIType.CLOUD: + scenarios = await client.get_scenarios() + else: + scenarios = [] except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: @@ -98,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception - setup = cast(Setup, setup) - scenarios = cast(list[Scenario], scenarios) - coordinator = OverkizDataUpdateCoordinator( hass, LOGGER, diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 2a549f1c24d..a756df4d0d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -9,7 +9,7 @@ } }, "local_or_cloud": { - "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.", "data": { "api_type": "API type" } From f48e94887180e7e550936951253329668a9bb0e9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 7 Dec 2023 09:35:22 +0100 Subject: [PATCH 206/927] Correct smtp error message string (#105148) --- homeassistant/components/smtp/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index e8347ef1a89..37250fa6447 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -7,7 +7,7 @@ }, "exceptions": { "remote_path_not_allowed": { - "message": "Cannot send email with attachment '{file_name}' form directory '{file_path}' which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + "message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." } } } From f8c216a5ea28cbf625f812c04fde9f7e89f0aaeb Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:37:20 +0100 Subject: [PATCH 207/927] Use brightness scaling util in HomeWizard Number entity (#105147) --- homeassistant/components/homewizard/number.py | 7 +++++-- tests/components/homewizard/snapshots/test_number.ambr | 2 +- tests/components/homewizard/test_number.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 58e0b02a06c..ced870d7072 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac1..5c7e71ea9ac 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 08154614833..a54f98899c6 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,7 +64,7 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError with pytest.raises( From 0728106c98c291a5e5f2f3dcecc17aebe083f97f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 09:50:21 +0100 Subject: [PATCH 208/927] Increase ping update interval to 30 seconds (#105199) --- homeassistant/components/ping/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index 5fe9d692bc3..f6bda9693b8 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(seconds=15), + update_interval=timedelta(seconds=30), ) async def _async_update_data(self) -> PingResult: From d61814a1edac1ec6cfdd54eb46967ca3041bf916 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:57:01 +0100 Subject: [PATCH 209/927] Add custom services for renson fans (#94497) Co-authored-by: Robert Resch --- homeassistant/components/renson/const.py | 1 + homeassistant/components/renson/fan.py | 88 +++++++++++++ homeassistant/components/renson/services.yaml | 117 ++++++++++++++++++ homeassistant/components/renson/strings.json | 81 ++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 homeassistant/components/renson/services.yaml diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py index 840e1ce428a..53bbd90c4b7 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 6bceca92db0..a60adccade5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,10 +7,13 @@ from typing import Any from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -41,6 +44,33 @@ SPEED_MAPPING = { Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ class RensonFan(RensonEntity, FanEntity): await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000..ad79af8649e --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 8aa7c6244ea..a826b5a3dd3 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -162,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } From de758fd7b39742e3b2bf35f6ae61dbe3f6808f66 Mon Sep 17 00:00:00 2001 From: dewdrop <81049573+dewdropawoo@users.noreply.github.com> Date: Thu, 7 Dec 2023 01:39:54 -0800 Subject: [PATCH 210/927] Add Seattle City Light virtual integration via Opower (#104997) --- homeassistant/components/scl/__init__.py | 1 + homeassistant/components/scl/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/scl/__init__.py create mode 100644 homeassistant/components/scl/manifest.json diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000..ae3b8d58f5e --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000..11fce2c4b47 --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d99487c2a60..36aae4f799b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4953,6 +4953,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", From 334673154c78c46d2548f58762cb1a2c969eb5d1 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Thu, 7 Dec 2023 10:00:26 +0000 Subject: [PATCH 211/927] Fix bug in roon incremental volume control. (#105201) --- homeassistant/components/roon/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d6128d26723..dda323c2c2a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -373,14 +373,14 @@ class RoonDevice(MediaPlayerEntity): def volume_up(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, -3) From 84329844fd0e2c3d680f0431cdfb790f89d9c574 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Dec 2023 13:25:23 +0100 Subject: [PATCH 212/927] Disable config flow progress in peco config flow (#105222) --- homeassistant/components/peco/config_flow.py | 18 ++---------- tests/components/peco/test_config_flow.py | 30 -------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 261cdb031bf..144495ec066 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -33,7 +33,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - meter_verification: bool = False meter_data: dict[str, str] = {} meter_error: dict[str, str] = {} @@ -53,17 +52,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except HttpError: self.meter_error = {"phone_number": "http_error", "type": "error"} - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - if self.meter_verification is True: - return self.async_show_progress_done(next_step_id="finish_smart_meter") - if user_input is None: return self.async_show_form( step_id="user", @@ -86,20 +78,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - self.meter_verification = True - if self.meter_error is not None: # Clear any previous errors, since the user may have corrected them self.meter_error = {} - self.hass.async_create_task(self._verify_meter(phone_number)) + await self._verify_meter(phone_number) self.meter_data = user_input - return self.async_show_progress( - step_id="user", - progress_action="verifying_meter", - ) + return await self.async_step_finish_smart_meter() async def async_step_finish_smart_meter( self, user_input: dict[str, Any] | None = None @@ -107,7 +94,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the finish smart meter step.""" if "phone_number" in self.meter_error: if self.meter_error["type"] == "error": - self.meter_verification = False return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index ca6759baeff..9ce87d707ff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -78,12 +78,6 @@ async def test_meter_value_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "invalid_phone_number"} @@ -107,12 +101,6 @@ async def test_incompatible_meter_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT assert result["reason"] == "incompatible_meter" @@ -135,12 +123,6 @@ async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "unresponsive_meter"} @@ -164,12 +146,6 @@ async def test_meter_http_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "http_error"} @@ -193,12 +169,6 @@ async def test_smart_meter(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Philadelphia - 1234567890" assert result["data"]["phone_number"] == "1234567890" From d80547519af76649cfa6173c215ac1dd31d07966 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 7 Dec 2023 14:57:08 +0100 Subject: [PATCH 213/927] Bump easyenergy lib to v2.1.0 (#105224) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 0e57133a89a..6f57ea6ed5f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==2.0.0"] + "requirements": ["easyenergy==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 702da75f928..58d4042cbb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -726,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.0.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 425cd2cb382..55ba9d169e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -592,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.0.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From d86abf214b214f7c80770f8879b35fa644dda283 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 7 Dec 2023 15:22:30 +0100 Subject: [PATCH 214/927] Bump energyzero lib to v2.1.0 (#105228) --- 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 7b1588eeb54..025f929a4f6 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==2.0.0"] + "requirements": ["energyzero==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58d4042cbb4..e4fcb0cb396 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==2.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55ba9d169e6..e46bcbbe862 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -613,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==2.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 From 83a1ca5e8373f44f084ba3d7f0b8531513209ce8 Mon Sep 17 00:00:00 2001 From: haimn Date: Thu, 7 Dec 2023 19:03:07 +0200 Subject: [PATCH 215/927] fix supportedFanOscillationModes is null (#105205) * fix supportedFanOscillationModes is null * set default supported_swings to None * return None if no fan oscillation modes listed --- homeassistant/components/smartthings/climate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 16558d2c795..b97ca06a471 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -497,14 +497,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Return the unit of measurement.""" return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] - def _determine_swing_modes(self) -> list[str]: + def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" + supported_swings = None supported_modes = self._device.status.attributes[ Attribute.supported_fan_oscillation_modes ][0] - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] + if supported_modes is not None: + supported_swings = [ + FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes + ] return supported_swings async def async_set_swing_mode(self, swing_mode: str) -> None: From dd902bc9566a8c44e0ccf5fc016ff79528c0b5d2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 19:47:14 +0100 Subject: [PATCH 216/927] Explicit check for None in Discovergy entity if condition (#105248) Fix checking for None in Discovergy --- homeassistant/components/discovergy/sensor.py | 1 + tests/components/discovergy/const.py | 2 +- tests/components/discovergy/snapshots/test_diagnostics.ambr | 2 +- tests/components/discovergy/snapshots/test_sensor.ambr | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index ed878fbb82e..9648492c2e4 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -183,6 +183,7 @@ async def async_setup_entry( for description in sensors for value_key in {description.key, *description.alternative_keys} if description.value_fn(coordinator.data, value_key, description.scale) + is not None ) async_add_entities(entities) diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 6c5428741af..5e596d7970f 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -67,7 +67,7 @@ LAST_READING = Reading( "energyOut": 55048723044000.0, "energyOut1": 0.0, "energyOut2": 0.0, - "power": 531750.0, + "power": 0.0, "power1": 142680.0, "power2": 138010.0, "power3": 251060.0, diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index 2a7dd6903af..e8d4eab1909 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -61,7 +61,7 @@ 'energyOut': 55048723044000.0, 'energyOut1': 0.0, 'energyOut2': 0.0, - 'power': 531750.0, + 'power': 0.0, 'power1': 142680.0, 'power2': 138010.0, 'power3': 251060.0, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 981d1119a93..2473af5012a 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -132,7 +132,7 @@ 'entity_id': 'sensor.electricity_teststrasse_1_total_power', 'last_changed': , 'last_updated': , - 'state': '531.75', + 'state': '0.0', }) # --- # name: test_sensor[gas last transmitted] From 2daa94b600df15758a1912c6fafa24f6f935315f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 7 Dec 2023 20:04:39 +0100 Subject: [PATCH 217/927] Fix check_date service in workday (#105241) * Fix check_date service in workday * Add test --- .../components/workday/binary_sensor.py | 21 ++++++++++++------- .../components/workday/test_binary_sensor.py | 12 +++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9cc96db7a57..2d1030c6b92 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -209,21 +209,26 @@ class IsWorkdaySensor(BinarySensorEntity): async def async_update(self) -> None: """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(dt_util.now()) + + async def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" # Default is no workday - self._attr_is_on = False + is_workday = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt_util.now() + timedelta(days=self._days_offset) + adjusted_date = check_date + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): - self._attr_is_on = True + is_workday = True if self.is_exclude(day_of_week, adjusted_date): - self._attr_is_on = False + is_workday = False - async def check_date(self, check_date: date) -> ServiceResponse: - """Check if date is workday or not.""" - holiday_date = check_date in self._obj_holidays - return {"workday": not holiday_date} + return is_workday diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 7457d2e0ada..a359d83d87d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -316,6 +316,18 @@ async def test_check_date_service( ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 17), # Saturday (no workday) + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + async def test_language_difference_english_language( hass: HomeAssistant, From 6666b796f2d08dc8409d954104d7c3350dd37742 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 21:18:10 +0100 Subject: [PATCH 218/927] Migrate auth tests to use freezegun (#105243) --- tests/auth/test_init.py | 5 +---- tests/components/auth/test_init.py | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b..9e9b48a07f6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a33ca702bcf..4088b1819fa 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: From 4c4ad9404fb427fa2be8d907e5e93c8686c2f9a7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 14:28:04 -0600 Subject: [PATCH 219/927] Don't return TTS URL in Assist pipeline (#105164) * Don't return TTS URL * Add test for empty queue --- .../components/assist_pipeline/pipeline.py | 10 +-- tests/components/assist_pipeline/test_init.py | 64 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 4f2a9a8d99b..ed9029d1c2c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field from enum import StrEnum import logging from pathlib import Path -from queue import Queue +from queue import Empty, Queue from threading import Thread import time from typing import TYPE_CHECKING, Any, Final, cast @@ -1010,8 +1010,8 @@ class PipelineRun: self.tts_engine = engine self.tts_options = tts_options - async def text_to_speech(self, tts_input: str) -> str: - """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" + async def text_to_speech(self, tts_input: str) -> None: + """Run text-to-speech portion of pipeline.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -1058,8 +1058,6 @@ class PipelineRun: PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) - return tts_media.url - def _capture_chunk(self, audio_bytes: bytes | None) -> None: """Forward audio chunk to various capturing mechanisms.""" if self.debug_recording_queue is not None: @@ -1246,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc( # Chunk of 16-bit mono audio at 16Khz if wav_writer is not None: wav_writer.writeframes(message) + except Empty: + pass # occurs when pipeline has unexpected error except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Unexpected error in debug recording thread") finally: diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 24a4a92536d..882d3a80fb3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,4 +1,5 @@ """Test Voice Assistant init.""" +import asyncio from dataclasses import asdict import itertools as it from pathlib import Path @@ -569,6 +570,69 @@ async def test_pipeline_saved_audio_write_error( ) +async def test_pipeline_saved_audio_empty_queue( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's an empty queue.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + # Force timeout in _pipeline_debug_recording_thread_proc + await asyncio.sleep(1) + yield b"not used" + + # Wrap original function to time out immediately + _pipeline_debug_recording_thread_proc = ( + assist_pipeline.pipeline._pipeline_debug_recording_thread_proc + ) + + def proc_wrapper(run_recording_dir, queue): + _pipeline_debug_recording_thread_proc( + run_recording_dir, queue, message_timeout=0 + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline._pipeline_debug_recording_thread_proc", + proc_wrapper, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + 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, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + async def test_wake_word_detection_aborted( hass: HomeAssistant, mock_stt_provider: MockSttProvider, From 8a4b761c78106a241840b867e8f96d8a9fe149e6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 21:55:37 +0100 Subject: [PATCH 220/927] Use freezegun in cert_expiry tests (#105125) --- tests/components/cert_expiry/test_init.py | 6 ++++-- tests/components/cert_expiry/test_sensors.py | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e..00f8a34fb0c 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f..18a70fa9ab6 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import socket import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from .helpers import future_timestamp, static_datetime from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) From c4d77877d21995e1747d649ae2cc0b2053e555cc Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 7 Dec 2023 23:11:08 +0200 Subject: [PATCH 221/927] Fix update of uncategorized OurGroceries items (#105255) * Fix update of uncategorized OurGroceries items * Address code review comments --- homeassistant/components/ourgroceries/todo.py | 2 +- tests/components/ourgroceries/test_todo.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 8115066d0fb..5b8d19e5aa1 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -89,7 +89,7 @@ class OurGroceriesTodoListEntity( if item.summary: api_items = self.coordinator.data[self._list_id]["list"]["items"] category = next( - api_item["categoryId"] + api_item.get("categoryId") for api_item in api_items if api_item["id"] == item.uid ) diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 65bbff0e601..8686c52d79b 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -142,12 +142,20 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] + ("items", "category"), + [ + ( + [{"id": "12345", "name": "Soda", "categoryId": "test_category"}], + "test_category", + ), + ([{"id": "12345", "name": "Uncategorized"}], None), + ], ) async def test_update_todo_item_summary( hass: HomeAssistant, setup_integration: None, ourgroceries: AsyncMock, + category: str | None, ) -> None: """Test for updating an item summary.""" @@ -171,7 +179,7 @@ async def test_update_todo_item_summary( ) assert ourgroceries.change_item_on_list args = ourgroceries.change_item_on_list.call_args - assert args.args == ("test_list", "12345", "test_category", "Milk") + assert args.args == ("test_list", "12345", category, "Milk") @pytest.mark.parametrize( From 00e87b9dff40015ac46d3a273ef3f83d99f95297 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Dec 2023 22:47:52 +0100 Subject: [PATCH 222/927] Migrate Gree to has entity name (#104739) * Migrate Gree to has entity name * Migrate Gree to has entity name --- homeassistant/components/gree/climate.py | 16 +++------ homeassistant/components/gree/entity.py | 8 +++-- homeassistant/components/gree/strings.json | 19 +++++++++++ homeassistant/components/gree/switch.py | 26 ++++++-------- .../gree/snapshots/test_climate.ambr | 4 +-- .../gree/snapshots/test_switch.ambr | 34 +++++++++---------- 6 files changed, 58 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index ba162173724..8d50cdf2aed 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,10 +38,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( @@ -52,6 +50,7 @@ from .const import ( FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -105,7 +104,7 @@ async def async_setup_entry( ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -120,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90d..c965ad45721 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ from .const import DOMAIN class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7..45911433b92 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7916df18abc..3c1893f7735 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -33,10 +33,6 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -66,33 +62,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -134,7 +130,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1..e28582ca2e9 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e..eff96ba1bd3 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), From d1aa690c2400f1e4046ac37ff18ec047d440eb1e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 22:58:09 +0100 Subject: [PATCH 223/927] Migrate non-component tests to use freezegun/freezer (#105142) --- tests/helpers/test_condition.py | 135 +++++++------- tests/helpers/test_event.py | 316 ++++++++++++++++---------------- tests/helpers/test_script.py | 11 +- tests/helpers/test_sun.py | 10 +- tests/helpers/test_template.py | 8 +- tests/test_core.py | 3 +- 6 files changed, 247 insertions(+), 236 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc..bcb6f4fa971 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 00ad580693e..245354a09a0 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ async def test_track_template_error_can_recover( async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ async def test_track_template_time_change( start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) - - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7e655a69c0a..c2bad6287ab 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1346,13 +1347,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1379,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab82..b6dc1616a48 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 79358ec588d..d1294d02f05 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1853,7 +1853,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1867,7 +1867,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1954,7 +1954,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2026,7 +2026,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/test_core.py b/tests/test_core.py index d5b3ba5f87d..ce1767f2755 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -1129,7 +1130,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() From e9f8e7ab5093039c9cf2ab0e9a4f4d1f98418aa4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 16:02:55 -0600 Subject: [PATCH 224/927] Add Wyoming satellite audio settings (#105261) * Add noise suppression level * Add auto gain and volume multiplier * Always use mock TTS dir in Wyoming tests * More tests --- homeassistant/components/wyoming/__init__.py | 7 +- homeassistant/components/wyoming/devices.py | 56 ++++++++++ homeassistant/components/wyoming/number.py | 102 ++++++++++++++++++ homeassistant/components/wyoming/satellite.py | 12 +++ homeassistant/components/wyoming/select.py | 55 +++++++++- homeassistant/components/wyoming/strings.json | 19 +++- tests/components/wyoming/__init__.py | 21 ++++ tests/components/wyoming/conftest.py | 6 ++ .../components/wyoming/test_binary_sensor.py | 5 +- tests/components/wyoming/test_number.py | 102 ++++++++++++++++++ tests/components/wyoming/test_select.py | 60 ++++++++++- tests/components/wyoming/test_switch.py | 9 ++ tests/components/wyoming/test_tts.py | 6 -- 13 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/wyoming/number.py create mode 100644 tests/components/wyoming/test_number.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 2cc9b7050a0..88e490d6dc9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -17,7 +17,12 @@ from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) -SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] +SATELLITE_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.NUMBER, +] __all__ = [ "ATTR_SPEAKER", diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index 90dad889707..bd7252bcf6b 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -19,10 +19,14 @@ class SatelliteDevice: is_active: bool = False is_enabled: bool = True pipeline_name: str | None = None + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 _is_active_listener: Callable[[], None] | None = None _is_enabled_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None + _audio_settings_listener: Callable[[], None] | None = None @callback def set_is_active(self, active: bool) -> None: @@ -48,6 +52,30 @@ class SatelliteDevice: if self._pipeline_listener is not None: self._pipeline_listener() + @callback + def set_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_listener() + @callback def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: """Listen for updates to is_active.""" @@ -63,6 +91,13 @@ class SatelliteDevice: """Listen for updates to pipeline.""" self._pipeline_listener = pipeline_listener + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_listener + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: """Return entity id for assist in progress binary sensor.""" ent_reg = er.async_get(hass) @@ -83,3 +118,24 @@ class SatelliteDevice: return ent_reg.async_get_entity_id( "select", DOMAIN, f"{self.satellite_id}-pipeline" ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000..5e769eeb06d --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index caf65db115e..1cc3fde2a9c 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -60,6 +60,7 @@ class WyomingSatellite: self.device.set_is_enabled_listener(self._enabled_changed) self.device.set_pipeline_listener(self._pipeline_changed) + self.device.set_audio_settings_listener(self._audio_settings_changed) async def run(self) -> None: """Run and maintain a connection to satellite.""" @@ -135,6 +136,12 @@ class WyomingSatellite: # Cancel any running pipeline self._audio_queue.put_nowait(None) + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + async def _run_once(self) -> None: """Run pipelines until an error occurs.""" self.device.set_is_active(False) @@ -227,6 +234,11 @@ class WyomingSatellite: end_stage=end_stage, tts_audio_output="wav", pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), ) ) diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index 2929ae79fa0..c04bad4bef8 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -1,12 +1,15 @@ -"""Select entities for VoIP integration.""" +"""Select entities for Wyoming integration.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -16,19 +19,34 @@ from .entity import WyomingSatelliteEntity if TYPE_CHECKING: from .models import DomainDataItem +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up VoIP switch entities.""" + """Set up Wyoming select entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] # Setup is only forwarded for satellites assert item.satellite is not None - async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(device), + ] + ) class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): @@ -45,3 +63,32 @@ class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelec """Select an option.""" await super().async_select_option(option) self.device.set_pipeline_name(option) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 19b6a513d4b..7b6be68aeb2 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -37,14 +37,29 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, - "noise_suppression": { - "name": "Noise suppression" + "noise_suppression_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } } }, "switch": { "satellite_enabled": { "name": "Satellite enabled" } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 899eda7ec1a..268ebef1d06 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,5 +1,6 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch from wyoming.event import Event from wyoming.info import ( @@ -15,6 +16,10 @@ from wyoming.info import ( WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -124,3 +129,19 @@ class MockAsyncTcpClient: self.host = host self.port = port return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index a30c1048eb6..f22ec7e9e16 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -16,6 +16,12 @@ from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + @pytest.fixture(autouse=True) async def init_components(hass: HomeAssistant): """Set up required components.""" diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py index 27294186a90..fba181a63ca 100644 --- a/tests/components/wyoming/test_binary_sensor.py +++ b/tests/components/wyoming/test_binary_sensor.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_assist_in_progress( hass: HomeAssistant, @@ -26,7 +28,8 @@ async def test_assist_in_progress( assert state.state == STATE_ON assert satellite_device.is_active - satellite_device.set_is_active(False) + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) state = hass.states.get(assist_in_progress_id) assert state is not None diff --git a/tests/components/wyoming/test_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000..084021d61a7 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py index cab699336fb..128aab57a1a 100644 --- a/tests/components/wyoming/test_select.py +++ b/tests/components/wyoming/test_select.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import reload_satellite + async def test_pipeline_select( hass: HomeAssistant, @@ -61,9 +63,16 @@ async def test_pipeline_select( assert state is not None assert state.state == "Test 1" - # async_pipeline_changed should have been called + # set function should have been called mock_pipeline_changed.assert_called_once_with("Test 1") + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + # Change back and check update listener pipeline_listener = Mock() satellite_device.set_pipeline_listener(pipeline_listener) @@ -81,3 +90,52 @@ async def test_pipeline_select( # listener should have been called pipeline_listener.assert_called_once() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index 0b05724d761..a39b7087f6d 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_satellite_enabled( hass: HomeAssistant, @@ -30,3 +32,10 @@ async def test_satellite_enabled( assert state is not None assert state.state == STATE_OFF assert not satellite_device.is_enabled + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 2f2a25558e4..301074e8ffb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -@pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): - """Mock the TTS cache dir with empty dir.""" - return mock_tts_cache_dir - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") From 43daeb26303208c0cf2183d548a705f840fa1fb5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 19:44:43 -0600 Subject: [PATCH 225/927] Set device id and forward errors to Wyoming satellites (#105266) * Set device id and forward errors * Fix tests --- .../components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/satellite.py | 12 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../wyoming/snapshots/test_stt.ambr | 2 +- tests/components/wyoming/test_satellite.py | 50 ++++++++++++++++++- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 540aaa9aeac..7174683fd18 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.3.0"], + "requirements": ["wyoming==1.4.0"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 1cc3fde2a9c..94f61c17047 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -9,6 +9,7 @@ import wave from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient +from wyoming.error import Error from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.tts import Synthesize, SynthesizeVoice @@ -239,6 +240,7 @@ class WyomingSatellite: auto_gain_dbfs=self.device.auto_gain, volume_multiplier=self.device.volume_multiplier, ), + device_id=self.device.device_id, ) ) @@ -333,6 +335,16 @@ class WyomingSatellite: if event.data and (tts_output := event.data["tts_output"]): media_id = tts_output["media_id"] self.hass.add_job(self._stream_tts(media_id)) + elif event.type == assist_pipeline.PipelineEventType.ERROR: + # Pipeline error + if event.data: + self.hass.add_job( + self._client.write_event( + Error( + text=event.data["message"], code=event.data["code"] + ).event() + ) + ) async def _connect(self) -> None: """Connect to satellite over TCP.""" diff --git a/requirements_all.txt b/requirements_all.txt index e4fcb0cb396..935f5f78075 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2760,7 +2760,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.3.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46bcbbe862..741b40b5ee4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2064,7 +2064,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.3.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 784f89b2ab8..b45b7508b28 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -6,7 +6,7 @@ 'language': 'en', }), 'payload': None, - 'type': 'transcibe', + 'type': 'transcribe', }), dict({ 'data': dict({ diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 06ae337a19c..50252007aa5 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -8,6 +8,7 @@ import wave from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.error import Error from wyoming.event import Event from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite @@ -96,6 +97,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.tts_audio_stop_event = asyncio.Event() self.tts_audio_chunk: AudioChunk | None = None + self.error_event = asyncio.Event() + self.error: Error | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -135,6 +139,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.tts_audio_chunk_event.set() elif AudioStop.is_type(event.type): self.tts_audio_stop_event.set() + elif Error.is_type(event.type): + self.error = Error.from_event(event) + self.error_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -175,8 +182,9 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await mock_client.connect_event.wait() await mock_client.run_satellite_event.wait() - mock_run_pipeline.assert_called() + mock_run_pipeline.assert_called_once() event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id # Start detecting wake word event_callback( @@ -458,3 +466,43 @@ async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None # Sensor should have been turned off assert not device.is_active + + +async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite error occurring during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline: + await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test code", "message": "test message"}, + ) + ) + + async with asyncio.timeout(1): + await mock_client.error_event.wait() + + assert mock_client.error is not None + assert mock_client.error.text == "test message" + assert mock_client.error.code == "test code" From 664d2410d546c729581a0ec138171b960196c741 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:51:59 +0200 Subject: [PATCH 226/927] Add OSO Energy integration (#70365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add OSO Energy integration * Add min/max for v40 level and bump pyosoenergyapi to 1.0.2 * OSO Energy address review comments * Bump pyosoenergyapi to 1.0.3 and remove scan interval * Remove unnecessary code * Update homeassistant/components/osoenergy/__init__.py Co-authored-by: Daniel Hjelseth Høyer * Fixes to latest version * Add support to set temperature * Update homeassistant/components/osoenergy/config_flow.py Co-authored-by: Daniel Hjelseth Høyer * Fixes after review * Remove unused code * Add support for translations and modify services * Apply suggestions from code review Co-authored-by: Robert Resch * Refactor services and constants according to the PR suggestions * Remove unnecessary code * Remove unused import in constants * Refactoring and support for multiple instances * Apply suggestions from code review Co-authored-by: Robert Resch * Refactor code and apply review suggestions * Bump pyosoenergyapi to 1.0.5 * Remove services to reduce initial PR * Remove extra state attributes and make OSO Entity generic --------- Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Robert Resch --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/osoenergy/__init__.py | 81 +++++++++ .../components/osoenergy/config_flow.py | 75 ++++++++ homeassistant/components/osoenergy/const.py | 3 + .../components/osoenergy/manifest.json | 9 + .../components/osoenergy/strings.json | 29 ++++ .../components/osoenergy/water_heater.py | 142 +++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/osoenergy/__init__.py | 1 + .../components/osoenergy/test_config_flow.py | 164 ++++++++++++++++++ 14 files changed, 522 insertions(+) create mode 100644 homeassistant/components/osoenergy/__init__.py create mode 100644 homeassistant/components/osoenergy/config_flow.py create mode 100644 homeassistant/components/osoenergy/const.py create mode 100644 homeassistant/components/osoenergy/manifest.json create mode 100644 homeassistant/components/osoenergy/strings.json create mode 100644 homeassistant/components/osoenergy/water_heater.py create mode 100644 tests/components/osoenergy/__init__.py create mode 100644 tests/components/osoenergy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 9e5044dff64..c012c8e686e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -903,6 +903,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e618db415c6..9bcc3daac17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -930,6 +930,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + 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.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "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%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa13faaf501..6315d2db46e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -348,6 +348,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 36aae4f799b..652fc11411e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4150,6 +4150,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 935f5f78075..77fe9c3801a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1955,6 +1955,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 741b40b5ee4..cdc63c097f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1478,6 +1478,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..d7250356ebe --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} From a7104919900ebd3f5b4d63bb31a820bf1964496e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Dec 2023 08:54:27 +0100 Subject: [PATCH 227/927] Bump actions/stale from 8.0.0 to 9.0.0 (#105275) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d..61dfef074a7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From 8879f7350a20df440e9db3ef7483d241dfab8e48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Dec 2023 08:55:25 +0100 Subject: [PATCH 228/927] Bump github/codeql-action from 2.22.8 to 2.22.9 (#105274) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7d9d4cd901..c9e6bb8fcc8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.8 + uses: github/codeql-action/init@v2.22.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.8 + uses: github/codeql-action/analyze@v2.22.9 with: category: "/language:python" From 8aacd3ea1bad9cb1e036c0e6f4f4671b0448ab15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Dec 2023 21:56:32 -1000 Subject: [PATCH 229/927] Bump habluetooth to 0.10.0 (#105118) --- 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 055eff43a91..5d54ae6ea82 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.8.0" + "habluetooth==0.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1673877b029..bb997222625 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.8.0 +habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 77fe9c3801a..0049dd4dd40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.8.0 +habluetooth==0.10.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdc63c097f7..02153886bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.8.0 +habluetooth==0.10.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 97991cfa46a99c53c003d325c076a43572ec7ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Dec 2023 22:00:23 -1000 Subject: [PATCH 230/927] Bump pyunifiprotect to 4.22.0 (#105265) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ee6f6d05548..045538aa2d1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 0049dd4dd40..6d1c95e88b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2258,7 +2258,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02153886bfd..857581cdf2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1694,7 +1694,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From b29e9e2df0a9a154109df89f2d7a3321c9da65ee Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:01:22 +0800 Subject: [PATCH 231/927] Fix AsusWrt invalid data type with tuple type (#105247) --- homeassistant/components/asuswrt/bridge.py | 6 ++++-- tests/components/asuswrt/conftest.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 83f99ecc76a..228da7f1a36 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__) _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") -_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_FuncType = Callable[ + [_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]] +] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] @@ -81,7 +83,7 @@ def handle_errors_and_zip( if isinstance(data, dict): return dict(zip(keys, list(data.values()))) - if not isinstance(data, list): + if not isinstance(data, (list, tuple)): raise UpdateFailed("Received invalid data type") return dict(zip(keys, data)) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 0f29c84c820..72cbc37d571 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -14,9 +14,9 @@ from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" -MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_BYTES_TOTAL = 60000000000, 50000000000 MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) From a09ccddaa3b6bc4b115b0861b02b1f3c6dcc2a29 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 Dec 2023 09:33:24 +0100 Subject: [PATCH 232/927] Fix Fritzbox light setup (#105232) --- homeassistant/components/fritzbox/light.py | 46 ++++++++++++---------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d31ccd180c4..8dc51e59738 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -38,11 +38,9 @@ async def async_setup_entry( FritzboxLight( coordinator, ain, - device.get_colors(), - device.get_color_temps(), ) for ain in coordinator.new_devices - if (device := coordinator.data.devices[ain]).has_lightbulb + if (coordinator.data.devices[ain]).has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -57,27 +55,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): self, coordinator: FritzboxDataUpdateCoordinator, ain: str, - supported_colors: dict, - supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup self._supported_hs: dict[int, list[int]] = {} - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] @property def is_on(self) -> bool: @@ -173,3 +154,28 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """Get light attributes from device after entity is added to hass.""" + await super().async_added_to_hass() + supported_colors = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_colors + ) + supported_color_temps = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_color_temps + ) + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] From 156dac394a3d1ea72dd81c4ff4361107d1446fc0 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:39:39 +0100 Subject: [PATCH 233/927] Add migration for old HomeWizard sensors (#105251) Co-authored-by: Franck Nijhof --- .../components/homewizard/__init__.py | 55 +++++++++- homeassistant/components/homewizard/const.py | 3 + homeassistant/components/homewizard/sensor.py | 1 - tests/components/homewizard/test_init.py | 103 ++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 036f6c077da..35b303a62e3 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,61 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old entry. + + The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does. + But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT. + + This migration migrates the old unique_id to the new one, if possible. + + Migration can be removed after 2024.6 + """ + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + replacements = { + "total_power_import_t1_kwh": "total_power_import_kwh", + "total_power_export_t1_kwh": "total_power_export_kwh", + } + + for old_id, new_id in replacements.items(): + if entry.unique_id.endswith(old_id): + new_unique_id = entry.unique_id.replace(old_id, new_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" coordinator = Coordinator(hass) @@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise + await _async_migrate_entries(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Abort reauth config flow if active diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ff065592283..d4692ee8bf0 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from homewizard_energy.models import Data, Device, State, System @@ -11,6 +12,8 @@ from homeassistant.const import Platform DOMAIN = "homewizard" PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +LOGGER = logging.getLogger(__package__) + # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 78cee9ee6fe..d980e66e0e4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -436,7 +436,6 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( HomeWizardSensorEntity(coordinator, description) for description in SENSORS diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 7dab8cfbb06..a4893c77f42 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -118,3 +120,104 @@ async def test_load_handles_homewizardenergy_exception( ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, ) + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration_does_not_trigger( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are not migrated when not possible.""" + mock_config_entry.add_to_hass(hass) + + old_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + new_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=new_unique_id, + config_entry=mock_config_entry, + ) + + assert old_entity.unique_id == old_unique_id + assert new_entity.unique_id == new_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = entity_registry.async_get(old_entity.entity_id) + assert entity + assert entity.unique_id == old_unique_id + assert entity.previous_unique_id is None + + entity = entity_registry.async_get(new_entity.entity_id) + assert entity + assert entity.unique_id == new_unique_id + assert entity.previous_unique_id is None From 0eb7034f89b48dca07c827428a75e918da0c6887 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 8 Dec 2023 10:59:38 +0100 Subject: [PATCH 234/927] Adjust stale bot policy for PRs (#105280) --- .github/workflows/stale.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 61dfef074a7..b51550767b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy + - name: 60 days stale PRs policy uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. From 9fb9ff4f902705e7c5dee12f45f594604f1f9ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Fri, 8 Dec 2023 11:46:27 +0100 Subject: [PATCH 235/927] Remove workaround for default lock code in Matter (#105173) * Matter: Remove workaround for default lock code * Review * Review 2 --- homeassistant/components/matter/lock.py | 15 +++------------ tests/components/matter/test_door_lock.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 8491f58e387..dd29638f765 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edc..991d23f3353 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +102,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -137,6 +139,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) From 949ca6bafcb632d147529aa7e0666a26fb559b9f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 8 Dec 2023 12:11:59 +0100 Subject: [PATCH 236/927] Update yarl to 1.9.4 (#105282) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bb997222625..1874807b892 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 +yarl==1.9.4 zeroconf==0.128.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 844fac7142f..d7764864168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index aa9a0ab0e5a..1b5b8d63c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,4 +32,4 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 From 88ddc2512972423f36447c555ba568fd6e90ed73 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Fri, 8 Dec 2023 06:40:09 -0500 Subject: [PATCH 237/927] Replace apcaccess dependency with aioapcaccess in apcupsd (#104571) * Replace apcaccess dependency with async version aioapcaccess * Upgrade the dependency to the latest version (v0.4.2) * Handle asyncio.IncompleteReadError --- .../components/apcupsd/coordinator.py | 11 +++----- .../components/apcupsd/manifest.json | 2 +- requirements_all.txt | 6 ++--- requirements_test_all.txt | 6 ++--- tests/components/apcupsd/__init__.py | 5 +--- tests/components/apcupsd/test_config_flow.py | 22 ++++++---------- tests/components/apcupsd/test_init.py | 23 ++++++++--------- tests/components/apcupsd/test_sensor.py | 25 ++++++------------- 8 files changed, 38 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index ae4c94a9382..98d464ec526 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from typing import Final -from apcaccess import status +import aioapcaccess from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -90,13 +90,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): Note that the result dict uses upper case for each resource, where our integration uses lower cases as keys internally. """ - async with asyncio.timeout(10): try: - raw = await self.hass.async_add_executor_job( - status.get, self._host, self._port - ) - result: OrderedDict[str, str] = status.parse(raw) - return result - except OSError as error: + return await aioapcaccess.request_status(self._host, self._port) + except (OSError, asyncio.IncompleteReadError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 55b66f0c0a0..b20e0c8aacf 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "silver", - "requirements": ["apcaccess==0.0.13"] + "requirements": ["aioapcaccess==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d1c95e88b3..7271bca2a16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,6 +196,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -433,9 +436,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 857581cdf2e..e634e36c4b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -175,6 +175,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -397,9 +400,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b0eee051331..4c4e0af8705 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,10 +95,7 @@ async def async_init_integration( entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", return_value=status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=status): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 48d57890320..6a69d4e974e 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -24,7 +24,7 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("apcaccess.status.get") as mock_get: + with patch("aioapcaccess.request_status") as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -38,10 +38,7 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with ( - patch("apcaccess.status.parse", return_value={}), # Returns no status. - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value={}): # Returns no status. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -64,11 +61,10 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup(), ): - mock_parse.return_value = MOCK_STATUS + mock_request_status.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject # the creation due same host / port. @@ -98,7 +94,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: # Now we change the serial number and add it again. This should be successful. another_device_status = copy(MOCK_STATUS) another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_parse.return_value = another_device_status + mock_request_status.return_value = another_device_status result = await hass.config_entries.flow.async_init( DOMAIN, @@ -112,8 +108,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS), - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status", return_value=MOCK_STATUS), _patch_setup() as mock_setup, ): result = await hass.config_entries.flow.async_init( @@ -152,12 +147,11 @@ async def test_flow_minimal_status( integration will vary. """ with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup() as mock_setup, ): status = MOCK_MINIMAL_STATUS | extra_status - mock_parse.return_value = status + mock_request_status.return_value = status result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 756fa07f120..c65efe25bb9 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -1,4 +1,5 @@ """Test init of APCUPSd integration.""" +import asyncio from collections import OrderedDict from unittest.mock import patch @@ -97,7 +98,11 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: assert state1.state != state2.state -async def test_connection_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + (OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)), +) +async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: """Test connection error during integration setup.""" entry = MockConfigEntry( version=1, @@ -109,10 +114,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", side_effect=OSError()), - patch("apcaccess.status.get"), - ): + with patch("aioapcaccess.request_status", side_effect=error): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -156,12 +158,9 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state != STATE_UNAVAILABLE assert pytest.approx(float(state.state)) == 14.0 - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status") as mock_request_status: # Mock a network error and then trigger an auto-polling event. - mock_parse.side_effect = OSError() + mock_request_status.side_effect = OSError() future = utcnow() + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -172,8 +171,8 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE # Reset the API to return a new status and update. - mock_parse.side_effect = None - mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + mock_request_status.side_effect = None + mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} future = future + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bff1b858216..24aae1d3937 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -127,10 +127,7 @@ async def test_state_update(hass: HomeAssistant) -> None: assert state.state == "14.0" new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} - with ( - patch("apcaccess.status.parse", return_value=new_status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=new_status): future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -154,11 +151,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): - mock_parse.return_value = MOCK_STATUS | { + with patch("aioapcaccess.request_status") as mock_request_status: + mock_request_status.return_value = MOCK_STATUS | { "LOADPCT": "15.0 Percent", "BCHARGE": "99.0 Percent", } @@ -174,8 +168,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: ) # Even if we requested updates for two entities, our integration should smartly # group the API calls to just one. - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 # The new state should be effective. state = hass.states.get("sensor.ups_load") @@ -194,10 +187,9 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): + with patch( + "aioapcaccess.request_status", return_value=MOCK_STATUS + ) as mock_request_status: # Fast-forward time to just pass the initial debouncer cooldown. future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) async_fire_time_changed(hass, future) @@ -207,5 +199,4 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, blocking=True, ) - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 From 8721327176306f3c4604725f6de2b5bedd1ac2e1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 14:35:01 +0100 Subject: [PATCH 238/927] Fix CI test_invalid_rrule_fix test by freezing the time (#105294) --- tests/components/google/test_calendar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index a70cd8aee9f..8466f5ad4eb 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1301,6 +1301,7 @@ async def test_event_differs_timezone( } +@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00") async def test_invalid_rrule_fix( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 6b7dace8d4fd1c2003c57847a054499790236dd9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 8 Dec 2023 14:37:02 +0100 Subject: [PATCH 239/927] Migrate samsungtv tests to use freezegun (#105286) --- .../components/samsungtv/test_media_player.py | 184 +++++++++++------- 1 file changed, 109 insertions(+), 75 deletions(-) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 674dea752a0..27a06ef3a13 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -11,6 +11,7 @@ from async_upnp_client.exceptions import ( UpnpError, UpnpResponseError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -165,7 +166,9 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") -async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_setup_websocket_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Test setup of platform from config entry.""" entity_id = f"{DOMAIN}.fake" @@ -194,9 +197,9 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -205,7 +208,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Test setup of platform from config entry.""" with patch( @@ -219,9 +222,9 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state @@ -229,21 +232,25 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_on( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("remote") -async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -252,16 +259,20 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -276,9 +287,9 @@ async def test_update_off_ws_no_power_state( remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -287,7 +298,11 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" with patch.object( @@ -308,9 +323,9 @@ async def test_update_off_ws_with_power_state( device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info next_update = mock_now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() remotews.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() @@ -324,9 +339,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() next_update = mock_now + timedelta(minutes=2) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -337,9 +352,9 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" next_update = mock_now + timedelta(minutes=3) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -350,7 +365,11 @@ async def test_update_off_ws_with_power_state( async def test_update_off_encryptedws( - hass: HomeAssistant, remoteencws: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remoteencws: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -364,9 +383,9 @@ async def test_update_off_encryptedws( remoteencws.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -374,7 +393,9 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") -async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_access_denied( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -383,13 +404,14 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> side_effect=exceptions.AccessDenied("Boom"), ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() assert [ flow @@ -403,6 +425,7 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_failure( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, @@ -416,8 +439,8 @@ async def test_update_ws_connection_failure( side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert ( @@ -432,7 +455,10 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -441,8 +467,8 @@ async def test_update_ws_connection_closed( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -451,7 +477,10 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -460,8 +489,8 @@ async def test_update_ws_unauthorized_error( remotews, "start_listening", side_effect=UnauthorizedError ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert [ @@ -475,7 +504,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -485,9 +514,9 @@ async def test_update_unhandled_response( side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -495,7 +524,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -505,17 +534,17 @@ async def test_connection_closed_during_update_can_recover( side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -653,7 +682,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remote") -async def test_state(hass: HomeAssistant) -> None: +async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.services.async_call( @@ -672,7 +701,8 @@ async def test_state(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + ): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -1393,7 +1423,11 @@ async def test_upnp_subscribe_events_upnpresponseerror( @pytest.mark.usefixtures("rest_api", "upnp_notify_server") async def test_upnp_re_subscribe_events( - hass: HomeAssistant, remotews: Mock, dmr_device: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + dmr_device: Mock, + mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1407,9 +1441,9 @@ async def test_upnp_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1417,9 +1451,9 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -1434,6 +1468,7 @@ async def test_upnp_re_subscribe_events( ) async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, mock_now: datetime, @@ -1452,9 +1487,9 @@ async def test_upnp_failed_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1462,9 +1497,8 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch.object( - dmr_device, "async_subscribe_services", side_effect=error - ): + with patch.object(dmr_device, "async_subscribe_services", side_effect=error): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() From a10f5808155e100bbbea3e2e257453aaddb3dc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Fri, 8 Dec 2023 14:42:42 +0100 Subject: [PATCH 240/927] =?UTF-8?q?Always=20set=20=5Fattr=5Fcurrent=5Fopti?= =?UTF-8?q?on=20in=20Nob=C3=B8=20Hub=20select=20entities=20(#105289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always set _attr_current_option in select entities. --- homeassistant/components/nobo_hub/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index b386e158420..2708dd75ffe 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -59,7 +59,7 @@ class NoboGlobalSelector(SelectEntity): nobo.API.OVERRIDE_MODE_ECO: "eco", } _attr_options = list(_modes.values()) - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, hub: nobo, override_type) -> None: """Initialize the global override selector.""" @@ -117,7 +117,7 @@ class NoboProfileSelector(SelectEntity): _attr_should_poll = False _profiles: dict[int, str] = {} _attr_options: list[str] = [] - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, zone_id: str, hub: nobo) -> None: """Initialize the week profile selector.""" From 45f7ffb34c4d77bc98155adfc20ec5fbe736e7eb Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:51:19 -0500 Subject: [PATCH 241/927] Add support for accessing Squeezebox over over https (#95088) * Supports access to squeezebox server behind https reverse proxy * Update squeezebox test * Update homeassistant/components/squeezebox/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/squeezebox/config_flow.py Co-authored-by: Robert Resch * Update squeezebox unit tests based on code review * Migration unit test * Run black on suggestions accepted in code review * Apply suggestions from code review Instead of upgrading squeezebox config, just assume a default of https=False. Co-authored-by: Erik Montnemery * Update test_init.py Remove migrate entry test since we are no longer migrating * Delete tests/components/squeezebox/test_init.py Remove unused test --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- .../components/squeezebox/config_flow.py | 11 +++++++++-- homeassistant/components/squeezebox/const.py | 1 + .../components/squeezebox/manifest.json | 2 +- .../components/squeezebox/media_player.py | 4 +++- .../components/squeezebox/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/squeezebox/test_config_flow.py | 19 +++++++++++++++---- 8 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2c96046b97c..b155c7eddc0 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_registry import async_get -from .const import DEFAULT_PORT, DOMAIN +from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -49,9 +49,15 @@ def _base_schema(discovery_info=None): ) else: base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) + base_schema.update( - {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } ) + return vol.Schema(base_schema) @@ -105,6 +111,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), + https=data[CONF_HTTPS], ) try: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d8b67504397..38a9ef7668f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,3 +6,4 @@ PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" DISCOVERY_TASK = "discovery_task" DEFAULT_PORT = 9000 SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +CONF_HTTPS = "https" diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 43c2868dd69..83ca3ff1b00 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.3"] + "requirements": ["pysqueezebox==0.7.1"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 03457c6a5c0..4e3d71eca24 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,7 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_HTTPS, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -126,6 +127,7 @@ async def async_setup_entry( password = config.get(CONF_PASSWORD) host = config[CONF_HOST] port = config[CONF_PORT] + https = config.get(CONF_HTTPS, False) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) @@ -134,7 +136,7 @@ async def async_setup_entry( session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(session, host, port, username, password) + lms = Server(session, host, port, username, password, https=https) async def _discovery(now=None): """Discover squeezebox players by polling server.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 756235ae247..fd232851e8a 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -16,7 +16,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "https": "Connect over https (requires reverse proxy)" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 7271bca2a16..df49d4e91c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2098,7 +2098,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e634e36c4b4..465df14d425 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.switchbee pyswitchbee==1.8.0 diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 87ba2d3be73..f12c7750cdf 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,7 @@ from pysqueezebox import Server from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import DOMAIN +from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -59,7 +59,13 @@ async def test_user_form(hass: HomeAssistant) -> None: # test the edit step result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""}, + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -68,6 +74,7 @@ async def test_user_form(hass: HomeAssistant) -> None: CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: "", + CONF_HTTPS: False, } await hass.async_block_till_done() @@ -107,7 +114,11 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UUID, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, + ) await hass.config_entries.async_add(entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -186,7 +197,7 @@ async def test_discovery_no_uuid(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "edit" From 05f67f745b5a12b75a517b4c19c42bc87f640f3e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 8 Dec 2023 16:45:34 +0100 Subject: [PATCH 242/927] Update frontend to 20231208.2 (#105299) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index af2ea6f9149..2a7ef1396d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231206.0"] + "requirements": ["home-assistant-frontend==20231208.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1874807b892..0a44f93324b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index df49d4e91c4..650451e0f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 465df14d425..7204bf9d40e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From b0ef9623f931a840e6b616b39a748ee11b7a8463 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Dec 2023 16:51:07 +0100 Subject: [PATCH 243/927] Add test for energy cost sensor for late price sensor (#105312) --- tests/components/energy/test_sensor.py | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f4a1f661f9b..fe3e6d08653 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -877,6 +877,114 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" +async def test_cost_sensor_handle_late_price_sensor( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test energy cost where the price sensor is not immediately available.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + price_attributes = { + ATTR_UNIT_OF_MEASUREMENT: f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.energy_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Initial state: 10kWh, price sensor not yet available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 10, + energy_attributes, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "unknown" + + # Energy use bumped by 10 kWh, price sensor still not yet available + hass.states.async_set( + "sensor.energy_consumption", + 20, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "unknown" + + # Energy use bumped by 10 kWh, price sensor now available + hass.states.async_set("sensor.energy_price", "1", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 30, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor available + hass.states.async_set( + "sensor.energy_consumption", + 40, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "10.0" + + # Energy use bumped by 10 kWh, price sensor no longer available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 50, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "10.0" + + # Energy use bumped by 10 kWh, price sensor again available + hass.states.async_set("sensor.energy_price", "2", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 60, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "50.0" + + @pytest.mark.parametrize( "unit", (UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), From e5a115ce1fff52da7dec0566e4928a18f2486322 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 16:54:02 +0100 Subject: [PATCH 244/927] Fix mqtt json light state updates using deprecated color handling (#105283) --- .../components/mqtt/light/schema_json.py | 3 + tests/components/mqtt/test_light_json.py | 87 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 8702069eab7..3479f1611d8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -406,6 +406,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): values["color_temp"], self.entity_id, ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 82b0b3467f4..c5c24c3ae79 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -725,6 +725,93 @@ async def test_controlling_state_via_topic2( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light_rgb/set", + "state_topic": "test_light_rgb/set", + "rgb": True, + "color_temp": True, + "brightness": True, + } + } + } + ], +) +async def test_controlling_the_state_with_legacy_color_handling( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test state updates for lights with a legacy color handling.""" + supported_color_modes = ["color_temp", "hs"] + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_mode") is None + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("supported_color_modes") == supported_color_modes + assert state.attributes.get("xy_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for _ in range(0, 2): + # Returned state after the light was turned on + # Receiving legacy color mode: rgb. + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' + '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' + '"bulb_mode": "color", "color_mode": "rgb" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "hs" + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (15.765, 100.0) + assert state.attributes.get("rgb_color") == (255, 67, 0) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.674, 0.322) + + # Returned state after the lights color mode was changed + # Receiving legacy color mode: color_temp + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, ' + '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' + '"color_mode": "color_temp" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "color_temp" + assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (28.125, 61.661) + assert state.attributes.get("rgb_color") == (255, 171, 97) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.513, 0.386) + + @pytest.mark.parametrize( "hass_config", [ From fdeb9e36c38f8ef278111472f14c37cf69a430f9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 8 Dec 2023 10:05:21 -0600 Subject: [PATCH 245/927] Use area id for context instead of name (#105313) --- .../components/conversation/default_agent.py | 2 +- tests/components/conversation/test_default_agent.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 99ebb4b60b1..aae8f67e1d8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -649,7 +649,7 @@ class DefaultAgent(AbstractConversationAgent): if device_area is None: return None - return {"area": device_area.name} + return {"area": device_area.id} def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents | None diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index fe94e2d5425..c68ec301280 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -307,8 +307,8 @@ async def test_device_area_context( turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_off_calls = async_mock_service(hass, "light", "turn_off") - area_kitchen = area_registry.async_get_or_create("kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("Kitchen") + area_bedroom = area_registry.async_get_or_create("Bedroom") # Create 2 lights in each area area_lights = defaultdict(list) @@ -323,7 +323,7 @@ async def test_device_area_context( "off", attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"}, ) - area_lights[area.name].append(light_entity) + area_lights[area.id].append(light_entity) # Create voice satellites in each area entry = MockConfigEntry() @@ -354,6 +354,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id # Verify only kitchen lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -375,6 +377,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -396,6 +400,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { From 1c7bd3f7297623bbe8d4f6a0cadd946863b41c30 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Fri, 8 Dec 2023 11:17:42 -0500 Subject: [PATCH 246/927] Add A. O. Smith integration (#104976) --- CODEOWNERS | 2 + homeassistant/components/aosmith/__init__.py | 53 +++++++ .../components/aosmith/config_flow.py | 61 +++++++ homeassistant/components/aosmith/const.py | 16 ++ .../components/aosmith/coordinator.py | 48 ++++++ homeassistant/components/aosmith/entity.py | 51 ++++++ .../components/aosmith/manifest.json | 10 ++ homeassistant/components/aosmith/strings.json | 20 +++ .../components/aosmith/water_heater.py | 149 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/aosmith/__init__.py | 1 + tests/components/aosmith/conftest.py | 74 +++++++++ .../aosmith/fixtures/get_devices.json | 46 ++++++ .../fixtures/get_devices_mode_pending.json | 46 ++++++ .../get_devices_no_vacation_mode.json | 42 +++++ .../get_devices_setpoint_pending.json | 46 ++++++ .../aosmith/snapshots/test_device.ambr | 29 ++++ .../aosmith/snapshots/test_water_heater.ambr | 27 ++++ tests/components/aosmith/test_config_flow.py | 84 ++++++++++ tests/components/aosmith/test_device.py | 23 +++ tests/components/aosmith/test_init.py | 71 +++++++++ tests/components/aosmith/test_water_heater.py | 147 +++++++++++++++++ 25 files changed, 1059 insertions(+) create mode 100644 homeassistant/components/aosmith/__init__.py create mode 100644 homeassistant/components/aosmith/config_flow.py create mode 100644 homeassistant/components/aosmith/const.py create mode 100644 homeassistant/components/aosmith/coordinator.py create mode 100644 homeassistant/components/aosmith/entity.py create mode 100644 homeassistant/components/aosmith/manifest.json create mode 100644 homeassistant/components/aosmith/strings.json create mode 100644 homeassistant/components/aosmith/water_heater.py create mode 100644 tests/components/aosmith/__init__.py create mode 100644 tests/components/aosmith/conftest.py create mode 100644 tests/components/aosmith/fixtures/get_devices.json create mode 100644 tests/components/aosmith/fixtures/get_devices_mode_pending.json create mode 100644 tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json create mode 100644 tests/components/aosmith/fixtures/get_devices_setpoint_pending.json create mode 100644 tests/components/aosmith/snapshots/test_device.ambr create mode 100644 tests/components/aosmith/snapshots/test_water_heater.ambr create mode 100644 tests/components/aosmith/test_config_flow.py create mode 100644 tests/components/aosmith/test_device.py create mode 100644 tests/components/aosmith/test_init.py create mode 100644 tests/components/aosmith/test_water_heater.py diff --git a/CODEOWNERS b/CODEOWNERS index 9bcc3daac17..d6e4cc764db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -86,6 +86,8 @@ build.json @home-assistant/supervisor /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex +/homeassistant/components/aosmith/ @bdr99 +/tests/components/aosmith/ @bdr99 /homeassistant/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya /homeassistant/components/apcupsd/ @yuxincs diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py new file mode 100644 index 00000000000..af780e012ae --- /dev/null +++ b/homeassistant/components/aosmith/__init__.py @@ -0,0 +1,53 @@ +"""The A. O. Smith integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_aosmith import AOSmithAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + +PLATFORMS: list[Platform] = [Platform.WATER_HEATER] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + coordinator: AOSmithCoordinator + client: AOSmithAPIClient + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up A. O. Smith from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(hass) + client = AOSmithAPIClient(email, password, session) + coordinator = AOSmithCoordinator(hass, client) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + coordinator=coordinator, client=client + ) + + 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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py new file mode 100644 index 00000000000..4ee29897070 --- /dev/null +++ b/homeassistant/components/aosmith/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for A. O. Smith integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for A. O. Smith.""" + + VERSION = 1 + + 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: + unique_id = user_input[CONF_EMAIL].lower() + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session + ) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py new file mode 100644 index 00000000000..06794582258 --- /dev/null +++ b/homeassistant/components/aosmith/const.py @@ -0,0 +1,16 @@ +"""Constants for the A. O. Smith integration.""" + +from datetime import timedelta + +DOMAIN = "aosmith" + +AOSMITH_MODE_ELECTRIC = "ELECTRIC" +AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" +AOSMITH_MODE_HYBRID = "HYBRID" +AOSMITH_MODE_VACATION = "VACATION" + +# Update interval to be used for normal background updates. +REGULAR_INTERVAL = timedelta(seconds=30) + +# Update interval to be used while a mode or setpoint change is in progress. +FAST_INTERVAL = timedelta(seconds=1) diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py new file mode 100644 index 00000000000..80cf85bc59a --- /dev/null +++ b/homeassistant/components/aosmith/coordinator.py @@ -0,0 +1,48 @@ +"""The data update coordinator for the A. O. Smith integration.""" + +import logging +from typing import Any + +from py_aosmith import ( + AOSmithAPIClient, + AOSmithInvalidCredentialsException, + AOSmithUnknownException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Custom data update coordinator for A. O. Smith integration.""" + + def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + self.client = client + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch latest data from API.""" + try: + devices = await self.client.get_devices() + except (AOSmithInvalidCredentialsException, AOSmithUnknownException) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + mode_pending = any( + device.get("data", {}).get("modePending") for device in devices + ) + setpoint_pending = any( + device.get("data", {}).get("temperatureSetpointPending") + for device in devices + ) + + if mode_pending or setpoint_pending: + self.update_interval = FAST_INTERVAL + else: + self.update_interval = REGULAR_INTERVAL + + return {device.get("junctionId"): device for device in devices} diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py new file mode 100644 index 00000000000..20061ca36b9 --- /dev/null +++ b/homeassistant/components/aosmith/entity.py @@ -0,0 +1,51 @@ +"""The base entity for the A. O. Smith integration.""" + + +from py_aosmith import AOSmithAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + + +class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]): + """Base entity for A. O. Smith.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.junction_id = junction_id + self._attr_device_info = DeviceInfo( + manufacturer="A. O. Smith", + name=self.device.get("name"), + model=self.device.get("model"), + serial_number=self.device.get("serial"), + suggested_area=self.device.get("install", {}).get("location"), + identifiers={(DOMAIN, junction_id)}, + sw_version=self.device.get("data", {}).get("firmwareVersion"), + ) + + @property + def device(self): + """Shortcut to get the device status from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) + + @property + def device_data(self): + """Shortcut to get the device data within the device status.""" + device = self.device + return None if device is None else device.get("data", {}) + + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_data.get("isOnline") is True diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json new file mode 100644 index 00000000000..2e3a459d7e2 --- /dev/null +++ b/homeassistant/components/aosmith/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aosmith", + "name": "A. O. Smith", + "codeowners": ["@bdr99"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aosmith", + "iot_class": "cloud_polling", + "quality_scale": "platinum", + "requirements": ["py-aosmith==1.0.1"] +} diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json new file mode 100644 index 00000000000..157895e04f8 --- /dev/null +++ b/homeassistant/components/aosmith/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your A. O. Smith credentials." + } + }, + "error": { + "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_account%]" + } + } +} diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py new file mode 100644 index 00000000000..8002373573f --- /dev/null +++ b/homeassistant/components/aosmith/water_heater.py @@ -0,0 +1,149 @@ +"""The water heater platform for the A. O. Smith integration.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, + DOMAIN, +) +from .coordinator import AOSmithCoordinator +from .entity import AOSmithEntity + +MODE_HA_TO_AOSMITH = { + STATE_OFF: AOSMITH_MODE_VACATION, + STATE_ECO: AOSMITH_MODE_HYBRID, + STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, + STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, +} +MODE_AOSMITH_TO_HA = { + AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, + AOSMITH_MODE_HYBRID: STATE_ECO, + AOSMITH_MODE_VACATION: STATE_OFF, +} + +# Operation mode to use when exiting away mode +DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID + +DEFAULT_SUPPORT_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith water heater platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for junction_id in data.coordinator.data: + entities.append(AOSmithWaterHeaterEntity(data.coordinator, junction_id)) + + async_add_entities(entities) + + +class AOSmithWaterHeaterEntity(AOSmithEntity, WaterHeaterEntity): + """The water heater entity for the A. O. Smith integration.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 95 + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = junction_id + + @property + def operation_list(self) -> list[str]: + """Return the list of supported operation modes.""" + op_modes = [] + for mode_dict in self.device_data.get("modes", []): + mode_name = mode_dict.get("mode") + ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + + # Filtering out STATE_OFF since it is handled by away mode + if ha_mode is not None and ha_mode != STATE_OFF: + op_modes.append(ha_mode) + + return op_modes + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + supports_vacation_mode = any( + mode_dict.get("mode") == AOSMITH_MODE_VACATION + for mode_dict in self.device_data.get("modes", []) + ) + + if supports_vacation_mode: + return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + + return DEFAULT_SUPPORT_FLAGS + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.device_data.get("temperatureSetpoint") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device_data.get("temperatureSetpointMaximum") + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + return self.device_data.get("mode") == AOSMITH_MODE_VACATION + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) + if aosmith_mode is not None: + await self.client.update_mode(self.junction_id, aosmith_mode) + + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get("temperature") + await self.client.update_setpoint(self.junction_id, temperature) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6315d2db46e..1b620f9018b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -46,6 +46,7 @@ FLOWS = { "androidtv_remote", "anova", "anthemav", + "aosmith", "apcupsd", "apple_tv", "aranet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 652fc11411e..9fc28e59ee2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -286,6 +286,12 @@ "integration_type": "virtual", "supported_by": "energyzero" }, + "aosmith": { + "name": "A. O. Smith", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "apache_kafka": { "name": "Apache Kafka", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 650451e0f46..0f59fe43782 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1534,6 +1534,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7204bf9d40e..3bf05465daf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,6 +1177,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 diff --git a/tests/components/aosmith/__init__.py b/tests/components/aosmith/__init__.py new file mode 100644 index 00000000000..89845dda42e --- /dev/null +++ b/tests/components/aosmith/__init__.py @@ -0,0 +1 @@ +"""Tests for the A. O. Smith integration.""" diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py new file mode 100644 index 00000000000..509e15024a9 --- /dev/null +++ b/tests/components/aosmith/conftest.py @@ -0,0 +1,74 @@ +"""Common fixtures for the A. O. Smith tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from py_aosmith import AOSmithAPIClient +import pytest + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, load_json_array_fixture + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "testemail@example.com", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + unique_id="unique_id", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aosmith.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def get_devices_fixture() -> str: + """Return the name of the fixture to use for get_devices.""" + return "get_devices" + + +@pytest.fixture +async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked client.""" + get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + + client_mock = MagicMock(AOSmithAPIClient) + client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + + return client_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with patch( + "homeassistant.components.aosmith.AOSmithAPIClient", return_value=mock_client + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json new file mode 100644 index 00000000000..e34c50cd270 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json new file mode 100644 index 00000000000..a12f1d95f13 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_mode_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": true, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json new file mode 100644 index 00000000000..249024e1f1e --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json @@ -0,0 +1,42 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json new file mode 100644 index 00000000000..4d6e7613cf2 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": true, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr new file mode 100644 index 00000000000..fb80dc06917 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'basement', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aosmith', + 'junctionId', + ), + }), + 'is_new': False, + 'manufacturer': 'A. O. Smith', + 'model': 'HPTS-50 200 202172000', + 'name': 'My water heater', + 'name_by_user': None, + 'serial_number': 'serial', + 'suggested_area': 'Basement', + 'sw_version': '2.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..2293a6c7b65 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'operation_list': list([ + 'eco', + 'heat_pump', + 'electric', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py new file mode 100644 index 00000000000..ff09f23ccbb --- /dev/null +++ b/tests/components/aosmith/test_config_flow.py @@ -0,0 +1,84 @@ +"""Test the A. O. Smith config flow.""" +from unittest.mock import AsyncMock, patch + +from py_aosmith import AOSmithInvalidCredentialsException +import pytest + +from homeassistant import config_entries +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.components.aosmith.conftest import FIXTURE_USER_INPUT + + +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.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error_key"), + [ + (AOSmithInvalidCredentialsException("Invalid credentials"), "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error_key: str, +) -> None: + """Test handling an exception and then recovering on the second attempt.""" + 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.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error_key} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result3["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/aosmith/test_device.py b/tests/components/aosmith/test_device.py new file mode 100644 index 00000000000..596f380290e --- /dev/null +++ b/tests/components/aosmith/test_device.py @@ -0,0 +1,23 @@ +"""Tests for the device created by the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of the device.""" + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "junctionId")}, + ) + + assert reg_device == snapshot diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py new file mode 100644 index 00000000000..8ab699e6f1c --- /dev/null +++ b/tests/components/aosmith/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the initialization of the A. O. Smith integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithUnknownException +import pytest + +from homeassistant.components.aosmith.const import ( + DOMAIN, + FAST_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: + """Test setup of the config entry.""" + mock_config_entry = init_integration + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry not ready.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("get_devices_fixture", "time_to_wait", "expected_call_count"), + [ + ("get_devices", REGULAR_INTERVAL, 1), + ("get_devices", FAST_INTERVAL, 0), + ("get_devices_mode_pending", FAST_INTERVAL, 1), + ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + ], +) +async def test_update( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + time_to_wait: timedelta, + expected_call_count: int, +) -> None: + """Test data update with differing intervals depending on device status.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert mock_client.get_devices.call_count == 1 + + freezer.tick(time_to_wait) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_client.get_devices.call_count == 1 + expected_call_count diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py new file mode 100644 index 00000000000..61cb159c82a --- /dev/null +++ b/tests/components/aosmith/test_water_heater.py @@ -0,0 +1,147 @@ +"""Tests for the water heater platform of the A. O. Smith integration.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, +) +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the water heater entity.""" + entry = entity_registry.async_get("water_heater.my_water_heater") + assert entry + assert entry.unique_id == "junctionId" + + state = hass.states.get("water_heater.my_water_heater") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture"), + ["get_devices_no_vacation_mode"], +) +async def test_state_away_mode_unsupported( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that away mode is not supported if the water heater does not support vacation mode.""" + state = hass.states.get("water_heater.my_water_heater") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + +@pytest.mark.parametrize( + ("hass_mode", "aosmith_mode"), + [ + (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), + (STATE_ECO, AOSMITH_MODE_HYBRID), + (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_mode: str, + aosmith_mode: str, +) -> None: + """Test setting the operation mode.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: hass_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the target temperature.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.my_water_heater", ATTR_TEMPERATURE: 120}, + ) + await hass.async_block_till_done() + + mock_client.update_setpoint.assert_called_once_with("junctionId", 120) + + +@pytest.mark.parametrize( + ("hass_away_mode", "aosmith_mode"), + [ + (True, AOSMITH_MODE_VACATION), + (False, AOSMITH_MODE_HYBRID), + ], +) +async def test_away_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_away_mode: bool, + aosmith_mode: str, +) -> None: + """Test turning away mode on/off.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_AWAY_MODE: hass_away_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) From 46d626280e884632b63fdce1b8601be91eb31842 Mon Sep 17 00:00:00 2001 From: Excentyl <35411484+Excentyl@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:46:08 +0000 Subject: [PATCH 247/927] Initialize energy_state without price (#97031) Co-authored-by: Erik --- homeassistant/components/energy/sensor.py | 5 +++++ tests/components/energy/test_sensor.py | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e9760a96aa4..834a9bbb1eb 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -317,6 +317,11 @@ class EnergyCostSensor(SensorEntity): try: energy_price = float(energy_price_state.state) except ValueError: + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities except + # price are in place. This means that the cost will update the first + # time the energy is updated after the price entity is in place. + self._reset(energy_state) return energy_price_unit: str | None = energy_price_state.attributes.get( diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index fe3e6d08653..522bbe5af06 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -924,7 +924,7 @@ async def test_cost_sensor_handle_late_price_sensor( await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "unknown" + assert state.state == "0.0" # Energy use bumped by 10 kWh, price sensor still not yet available hass.states.async_set( @@ -935,7 +935,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "unknown" + assert state.state == "0.0" # Energy use bumped by 10 kWh, price sensor now available hass.states.async_set("sensor.energy_price", "1", price_attributes) @@ -947,7 +947,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "0.0" + assert state.state == "20.0" # Energy use bumped by 10 kWh, price sensor available hass.states.async_set( @@ -958,7 +958,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "10.0" + assert state.state == "30.0" # Energy use bumped by 10 kWh, price sensor no longer available hass.states.async_set("sensor.energy_price", "unknown", price_attributes) @@ -970,7 +970,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "10.0" + assert state.state == "30.0" # Energy use bumped by 10 kWh, price sensor again available hass.states.async_set("sensor.energy_price", "2", price_attributes) @@ -982,7 +982,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "50.0" + assert state.state == "70.0" @pytest.mark.parametrize( From af715a4b9a21a7c855afc67bec005d0e977c8987 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Dec 2023 18:13:34 +0100 Subject: [PATCH 248/927] Add workaround for orjson not handling subclasses of str (#105314) Co-authored-by: Franck Nijhof --- homeassistant/util/json.py | 14 +++++++++++--- tests/util/test_json.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index ac18d43727c..1af35c604eb 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -33,9 +33,17 @@ class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" -json_loads: Callable[[bytes | bytearray | memoryview | str], JsonValueType] -json_loads = orjson.loads -"""Parse JSON data.""" +def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: + """Parse JSON data. + + This adds a workaround for orjson not handling subclasses of str, + https://github.com/ijl/orjson/issues/445. + """ + if type(__obj) in (bytes, bytearray, memoryview, str): + return orjson.loads(__obj) # type:ignore[no-any-return] + if isinstance(__obj, str): + return orjson.loads(str(__obj)) # type:ignore[no-any-return] + return orjson.loads(__obj) # type:ignore[no-any-return] def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: diff --git a/tests/util/test_json.py b/tests/util/test_json.py index b3bccf71b58..ff0f1ed8392 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,10 +1,12 @@ """Test Home Assistant json utility functions.""" from pathlib import Path +import orjson import pytest from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import ( + json_loads, json_loads_array, json_loads_object, load_json, @@ -153,3 +155,20 @@ async def test_deprecated_save_json( save_json(fname, TEST_JSON_A) assert "uses save_json from homeassistant.util.json" in caplog.text assert "should be updated to use homeassistant.helpers.json module" in caplog.text + + +async def test_loading_derived_class(): + """Test loading data from classes derived from str.""" + + class MyStr(str): + pass + + class MyBytes(bytes): + pass + + assert json_loads('"abc"') == "abc" + assert json_loads(MyStr('"abc"')) == "abc" + + assert json_loads(b'"abc"') == "abc" + with pytest.raises(orjson.JSONDecodeError): + assert json_loads(MyBytes(b'"abc"')) == "abc" From 4bb0e13cda023c02819b4966544dc41ba38ec83c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 20:57:53 +0100 Subject: [PATCH 249/927] Workaround `to_json` template filter in parsing dict key (#105327) * Work-a-round orjson for `to_json` fiter in case dict key is str subclass * Add option instead * Remove json.dumps work-a-round * Update homeassistant/helpers/template.py * Fix test --------- Co-authored-by: Erik Montnemery --- homeassistant/helpers/template.py | 4 ++++ tests/helpers/test_template.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 721ac8bd5be..df8b1c1e019 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2125,6 +2125,10 @@ def to_json( option = ( ORJSON_PASSTHROUGH_OPTIONS + # OPT_NON_STR_KEYS is added as a workaround to + # ensure subclasses of str are allowed as dict keys + # See: https://github.com/ijl/orjson/issues/445 + | orjson.OPT_NON_STR_KEYS | (orjson.OPT_INDENT_2 if pretty_print else 0) | (orjson.OPT_SORT_KEYS if sort_keys else 0) ) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d1294d02f05..58d52dfc395 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1233,6 +1233,22 @@ def test_to_json(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ {'Foo': now()} | to_json }}", hass).async_render() + # Test special case where substring class cannot be rendered + # See: https://github.com/ijl/orjson/issues/445 + class MyStr(str): + pass + + expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' + test_dict = { + MyStr("mykey2"): "myvalue2", + MyStr("mykey1"): 11.0, + MyStr("mykey3"): ["opt3b", "opt3a"], + } + actual_result = template.Template( + "{{ test_dict | to_json(sort_keys=True) }}", hass + ).async_render(parse_result=False, variables={"test_dict": test_dict}) + assert actual_result == expected_result + def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" From 99cf4a6b2d99ce576cf2aee8665ac1388e67d82a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Dec 2023 21:13:37 +0100 Subject: [PATCH 250/927] Add rollback on exception that needs rollback in SQL (#104948) --- homeassistant/components/sql/sensor.py | 2 ++ tests/components/sql/test_sensor.py | 48 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 3fdc6b2c079..c4e6db4c623 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -362,6 +362,8 @@ class SQLSensor(ManualTriggerSensorEntity): self._query, redact_credentials(str(err)), ) + sess.rollback() + sess.close() return for res in result.mappings(): diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cb988d3f2d4..cdc9a8e07a6 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError @@ -12,6 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ICON, @@ -21,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -570,3 +573,48 @@ async def test_attributes_from_entry_config( assert state.attributes["unit_of_measurement"] == "MiB" assert "device_class" not in state.attributes assert "state_class" not in state.attributes + + +async def test_query_recover_from_rollback( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the SQL sensor.""" + config = { + "db_url": "sqlite://", + "query": "SELECT 5 as value", + "column": "value", + "name": "Select value SQL query", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + platforms = async_get_platforms(hass, "sql") + sql_entity = platforms[0].entities["sensor.select_value_sql_query"] + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes["value"] == 5 + + with patch.object( + sql_entity, + "_lambda_stmt", + _generate_lambda_stmt("Faulty syntax create operational issue"), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "sqlite3.OperationalError" in caplog.text + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") is None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") == 5 From 46e75ed94be67a6252306c7af4cd14e66ba30cb0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:15:33 +0100 Subject: [PATCH 251/927] Bump plugwise to v0.34.5 (#105330) --- homeassistant/components/plugwise/climate.py | 10 +++++++ .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 5 ++++ .../fixtures/adam_jip/device_list.json | 13 ++++++++++ .../all_data.json | 1 + .../device_list.json | 20 ++++++++++++++ .../anna_heatpump_heating/device_list.json | 5 ++++ .../fixtures/m_adam_cooling/all_data.json | 3 +++ .../fixtures/m_adam_cooling/device_list.json | 8 ++++++ .../fixtures/m_adam_heating/all_data.json | 26 +++++++++++++++++++ .../fixtures/m_adam_heating/device_list.json | 8 ++++++ .../m_anna_heatpump_cooling/device_list.json | 5 ++++ .../m_anna_heatpump_idle/device_list.json | 5 ++++ .../fixtures/p1v3_full_option/all_data.json | 1 + .../p1v3_full_option/device_list.json | 1 + .../fixtures/p1v4_442_triple/all_data.json | 1 + .../fixtures/p1v4_442_triple/device_list.json | 1 + .../fixtures/stretch_v31/all_data.json | 1 + .../fixtures/stretch_v31/device_list.json | 10 +++++++ .../plugwise/snapshots/test_diagnostics.ambr | 1 + tests/components/plugwise/test_climate.py | 8 +++--- 23 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 tests/components/plugwise/fixtures/adam_jip/device_list.json create mode 100644 tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json create mode 100644 tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_adam_cooling/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_adam_heating/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json create mode 100644 tests/components/plugwise/fixtures/p1v3_full_option/device_list.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json create mode 100644 tests/components/plugwise/fixtures/stretch_v31/device_list.json diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index efad1b7466b..84e0619773b 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -160,6 +160,16 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Keep track of the previous action-mode self._previous_action_mode(self.coordinator) + # Adam provides the hvac_action for each thermostat + if (control_state := self.device.get("control_state")) == "cooling": + return HVACAction.COOLING + if control_state == "heating": + return HVACAction.HEATING + if control_state == "preheating": + return HVACAction.PREHEATING + if control_state == "off": + return HVACAction.IDLE + heater: str = self.coordinator.data.gateway["heater_id"] heater_data = self.coordinator.data.devices[heater] if heater_data["binary_sensors"]["heating_state"]: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1373ba40fa3..bb2b428bf19 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.3"], + "requirements": ["plugwise==0.34.5"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0f59fe43782..192d753a8c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bf05465daf..cc85c9bc58a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index dacee20c644..37566e1d39e 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,6 +4,7 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -99,6 +100,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -155,6 +157,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -265,6 +268,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -300,6 +304,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", + "item_count": 219, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/adam_jip/device_list.json new file mode 100644 index 00000000000..049845bc828 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_jip/device_list.json @@ -0,0 +1,13 @@ +[ + "b5c2386c6f6342669e50fe49dd05b188", + "e4684553153b44afbef2200885f379dc", + "a6abc6a129ee499c88a4d420cc413b47", + "1346fbd8498d4dbcab7e18d51b771f3d", + "833de10f269c4deab58fb9df69901b4e", + "6f3e9d7084214c21b9dfa46f6eeb8700", + "f61f1a2535f54f52ad006a3d18e459ca", + "d4496250d0e942cfa7aea3476e9070d5", + "356b65335e274d769c338223e7af9c33", + "1da4d325838e4ad8aac12177214505c9", + "457ce8414de24596a2d5e7dbc9c7682f" +] diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 6e6da1aa272..279fe6b8a43 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -468,6 +468,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", + "item_count": 315, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json new file mode 100644 index 00000000000..104a723e463 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json @@ -0,0 +1,20 @@ +[ + "fe799307f1624099878210aa0b9f1475", + "90986d591dcd426cae3ec3e8111ff730", + "df4a4a8169904cdb9c03d61a21f42140", + "b310b72a0e354bfab43089919b9a88bf", + "a2c3583e0a6349358998b760cea82d2a", + "b59bcebaf94b499ea7d46e4a66fb62d8", + "d3da73bde12a47d5a6b8f9dad971f2ec", + "21f2b542c49845e6bb416884c55778d6", + "78d1126fc4c743db81b61c20e88342a7", + "cd0ddb54ef694e11ac18ed1cbce5dbbd", + "4a810418d5394b3f82727340b91ba740", + "02cf28bfec924855854c544690a609ef", + "a28f588dc4a049a483fd03a30361ad3a", + "6a3bf693d05e48e0b460c815a4fdd09d", + "680423ff840043738f42cc7f1ff97a36", + "f1fee6043d3642a9b0a65297455f008e", + "675416a629f343c495449970e2ca37b5", + "e7693eb9582644e5b865dba8d4447cf1" +] diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 624547155a3..2e1063d14d3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -53,6 +53,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", @@ -102,6 +103,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -148,6 +150,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index e8a72c9b3fb..81d60bed9d4 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,5 +1,28 @@ { "devices": { + "01234567890abcdefghijklmnopqrstu": { + "available": false, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, "056ee145a816487eaa69243c3280f8bf": { "available": true, "binary_sensors": { @@ -58,6 +81,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", @@ -101,6 +125,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -147,6 +172,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index 0e0b3c51a07..0a47893c077 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -42,6 +42,7 @@ }, "gateway": { "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", + "item_count": 31, "notifications": {}, "smile_name": "Smile P1" } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json new file mode 100644 index 00000000000..8af35165c7e --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json @@ -0,0 +1 @@ +["cd3e822288064775a7c4afcdd70bdda2", "e950c7d5e1ee407a858e2a8b5016c8b3"] diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index d503bd3a59d..ecda8049163 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -51,6 +51,7 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", + "item_count": 40, "notifications": { "97a04c0c263049b29350a660b4cdd01e": { "warning": "The Smile P1 is not connected to a smart meter." diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json new file mode 100644 index 00000000000..7b301f50924 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json @@ -0,0 +1 @@ +["03e65b16e4b247a29ae0d75a78cb492e", "b82b6b3322484f2ea4e25e0bd5f3d61f"] diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 8604aaae10e..6b1012b0d87 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -135,6 +135,7 @@ }, "gateway": { "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "item_count": 83, "notifications": {}, "smile_name": "Stretch" } diff --git a/tests/components/plugwise/fixtures/stretch_v31/device_list.json b/tests/components/plugwise/fixtures/stretch_v31/device_list.json new file mode 100644 index 00000000000..b2c839ae9d3 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/device_list.json @@ -0,0 +1,10 @@ +[ + "0000aaaa0000aaaa0000aaaa0000aa00", + "5871317346d045bc9f6b987ef25ee638", + "e1c884e7dede431dadee09506ec4f859", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "059e4d03c7a34d278add5c7a4a781d19", + "d950b314e9d8499f968e6db8d82ef78c", + "d03738edfcc947f7b8f4573571d90d2d" +] diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 597b9710ec5..29f23a137fb 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -500,6 +500,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'item_count': 315, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c14fd802e3b..c5ab3a209c2 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -65,7 +65,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "preheating" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -75,7 +75,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -101,7 +101,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "heating" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "heat" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "heating" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -124,7 +124,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "cooling" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "cool" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "cooling" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True From a7845406a52b9967c944a158572fa6caf062b8de Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 8 Dec 2023 21:18:09 +0100 Subject: [PATCH 252/927] Migrate homeassistant component tests to use freezegun (#105333) --- .../homeassistant/triggers/test_time.py | 195 +++++----- .../triggers/test_time_pattern.py | 338 +++++++++--------- 2 files changed, 264 insertions(+), 269 deletions(-) diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index b4554f1a4e6..513827b5432 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -38,34 +39,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", - "id": "{{ trigger.id}}", - }, + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", + "id": "{{ trigger.id}}", }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, calls, has_date, has_time + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -107,24 +107,22 @@ async def test_if_fires_using_at_input_datetime( time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "input_datetime.trigger"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -161,7 +159,9 @@ async def test_if_fires_using_at_input_datetime( ) -async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_multiple_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() @@ -169,26 +169,23 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -203,7 +200,9 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_using_wrong_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -214,25 +213,23 @@ async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: year=now.year + 1, hour=1, minute=0, second=0 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - with assert_setup_component(1, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() + freezer.move_to(time_that_will_not_match_right_away) + with assert_setup_component(1, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( @@ -409,7 +406,9 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at_sensor( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -424,24 +423,22 @@ async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index e7a6a98bb96..0f6a075eb6e 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -1,7 +1,7 @@ """The tests for the time_pattern automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -27,33 +27,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_hour_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=3 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() @@ -72,30 +72,30 @@ async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_minute_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) @@ -103,30 +103,30 @@ async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_second_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) @@ -135,31 +135,29 @@ async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=15 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "30", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "30", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, time_that_will_not_match_right_away + timedelta(seconds=15) @@ -169,30 +167,30 @@ async def test_if_fires_when_second_as_string_matches( assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_all_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=4 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) @@ -202,30 +200,30 @@ async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/10", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) @@ -235,31 +233,31 @@ async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_minutes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) @@ -269,30 +267,30 @@ async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_hours( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) @@ -302,25 +300,25 @@ async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_default_values(hass: HomeAssistant, calls) -> None: +async def test_default_values( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) From 12019ec77c1567d49ad14ba1dc71611085e657e6 Mon Sep 17 00:00:00 2001 From: Viktor Andersson <30777521+VIKTORVAV99@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:22:34 +0100 Subject: [PATCH 253/927] Add myself as code owner for co2signal (#105302) --- CODEOWNERS | 4 ++-- homeassistant/components/co2signal/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d6e4cc764db..b5ae219bb1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -207,8 +207,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/co2signal/ @jpbede -/tests/components/co2signal/ @jpbede +/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 +/tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index d82af5b5034..f91232c1a28 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": ["@jpbede"], + "codeowners": ["@jpbede", "@VIKTORVAV99"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", From 6a6956573fd534feae497b3773401faddbca7860 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 8 Dec 2023 15:30:41 -0500 Subject: [PATCH 254/927] Add missing configuration for services.yaml in blink (#105310) --- homeassistant/components/blink/services.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 95f4d33f91f..f6420e7f004 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -8,6 +8,10 @@ trigger_camera: domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: name: required: true @@ -21,6 +25,10 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: name: required: true @@ -34,6 +42,10 @@ save_recent_clips: text: send_pin: + target: + entity: + integration: blink + domain: camera fields: pin: example: "abc123" From e1df1f9ffe46953edc2fefe8b1b78b9ebb5a35f1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 9 Dec 2023 11:15:57 +1000 Subject: [PATCH 255/927] Replace aiohttp mock with patch in Advantage Air (#104932) Co-authored-by: J. Nick Koston --- tests/components/advantage_air/__init__.py | 25 ++- tests/components/advantage_air/conftest.py | 20 ++ .../advantage_air/test_binary_sensor.py | 26 +-- .../components/advantage_air/test_climate.py | 195 +++++++----------- .../advantage_air/test_config_flow.py | 57 +++-- tests/components/advantage_air/test_cover.py | 104 +++------- .../advantage_air/test_diagnostics.py | 12 +- tests/components/advantage_air/test_init.py | 29 +-- tests/components/advantage_air/test_light.py | 99 ++------- tests/components/advantage_air/test_select.py | 36 +--- tests/components/advantage_air/test_sensor.py | 45 +--- tests/components/advantage_air/test_switch.py | 77 ++----- tests/components/advantage_air/test_update.py | 19 +- 13 files changed, 252 insertions(+), 492 deletions(-) create mode 100644 tests/components/advantage_air/conftest.py diff --git a/tests/components/advantage_air/__init__.py b/tests/components/advantage_air/__init__.py index b826e3ac7ce..05d98e957bb 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -1,12 +1,14 @@ """Tests for the Advantage Air component.""" +from unittest.mock import AsyncMock, patch + from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -TEST_SYSTEM_DATA = load_fixture("advantage_air/getSystemData.json") -TEST_SET_RESPONSE = load_fixture("advantage_air/setAircon.json") +TEST_SYSTEM_DATA = load_json_object_fixture("getSystemData.json", DOMAIN) +TEST_SET_RESPONSE = None USER_INPUT = { CONF_IP_ADDRESS: "1.2.3.4", @@ -25,6 +27,22 @@ TEST_SET_THING_URL = ( ) +def patch_get(return_value=TEST_SYSTEM_DATA, side_effect=None): + """Patch the Advantage Air async_get method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air.async_get", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + +def patch_update(return_value=True, side_effect=None): + """Patch the Advantage Air async_set method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air._endpoint.async_update", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + async def add_mock_config(hass): """Create a fake Advantage Air Config Entry.""" entry = MockConfigEntry( @@ -33,6 +51,7 @@ async def add_mock_config(hass): unique_id="0123456", data=USER_INPUT, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/conftest.py b/tests/components/advantage_air/conftest.py new file mode 100644 index 00000000000..9da0a176309 --- /dev/null +++ b/tests/components/advantage_air/conftest.py @@ -0,0 +1,20 @@ +"""Fixtures for advantage_air.""" +from __future__ import annotations + +import pytest + +from . import patch_get, patch_update + + +@pytest.fixture +def mock_get(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_get() as mock_get: + yield mock_get + + +@pytest.fixture +def mock_update(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_update() as mock_get: + yield mock_get diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index c6d055f396a..19b0dba2eda 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta +from unittest.mock import AsyncMock from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON @@ -7,37 +8,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test binary sensor setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First Air Filter entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) @@ -83,6 +67,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -91,6 +76,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state @@ -105,6 +91,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -113,6 +100,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index a1eb886cbd0..ba97644501f 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -1,20 +1,10 @@ """Test the Advantage Air Climate Platform.""" -from json import loads +from unittest.mock import AsyncMock + +from advantage_air import ApiError import pytest -from homeassistant.components.advantage_air.climate import ( - ADVANTAGE_AIR_COOL_TARGET, - ADVANTAGE_AIR_HEAT_TARGET, - HASS_FAN_MODES, - HASS_HVAC_MODES, -) -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -37,35 +27,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_update -async def test_climate_async_setup_entry( +async def test_climate_myzone_main( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: - """Test climate platform.""" + """Test climate platform main entity.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - # Test MyZone Climate Entity + # Test MyZone main climate entity entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state @@ -80,19 +55,24 @@ async def test_climate_async_setup_entry( assert entry.unique_id == "uniqueid-ac1" # Test setting HVAC Mode + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert data["ac1"]["info"]["mode"] == HASS_HVAC_MODES[HVACMode.FAN_ONLY] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off with HVAC Mode await hass.services.async_call( @@ -101,26 +81,17 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() - # Test changing Fan Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["fan"] == HASS_FAN_MODES[FAN_LOW] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test changing Temperature await hass.services.async_call( @@ -129,12 +100,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["setTemp"] == 25 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning On await hass.services.async_call( @@ -143,12 +110,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off await hass.services.async_call( @@ -157,12 +120,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + +async def test_climate_myzone_zone( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform myzone zone entity.""" + + await add_mock_config(hass) # Test Climate Zone Entity entity_id = "climate.myzone_zone_open_with_sensor" @@ -184,14 +154,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Climate Zone Off await hass.services.async_call( @@ -200,13 +164,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -214,11 +173,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + mock_update.assert_called_once() + mock_update.reset_mock() - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + +async def test_climate_myauto_main( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform zone entity.""" + + await add_mock_config(hass) # Test MyAuto Climate Entity entity_id = "climate.myauto" @@ -231,44 +198,34 @@ async def test_climate_async_setup_entry( assert entry assert entry.unique_id == "uniqueid-ac3" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: [entity_id], - ATTR_TARGET_TEMP_LOW: 21, - ATTR_TARGET_TEMP_HIGH: 23, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 - assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 + with patch_update() as mock_update: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_LOW: 21, + ATTR_TARGET_TEMP_HIGH: 23, + }, + blocking=True, + ) + mock_update.assert_called_once() async def test_climate_async_failed_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test climate change failure.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - exc=SyntaxError, - ) - await add_mock_config(hass) - with pytest.raises(HomeAssistantError): + mock_update.side_effect = ApiError + await add_mock_config(hass) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index fc74df5538b..64d445a0b20 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Advantage Air config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from advantage_air import ApiError from homeassistant import config_entries, data_entry_flow from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, USER_INPUT - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TEST_SYSTEM_DATA, USER_INPUT -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form(hass: HomeAssistant) -> None: """Test that form shows up.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -26,6 +21,9 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result1["errors"] == {} with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get, patch( "homeassistant.components.advantage_air.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -34,43 +32,44 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> USER_INPUT, ) await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + mock_get.assert_called_once() - assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + USER_INPUT, + ) assert result4["type"] == data_entry_flow.FlowResultType.ABORT -async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(side_effect=ApiError), + ) as mock_get: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + mock_get.assert_called_once() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index af516d16e6e..8166b5da941 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -1,10 +1,6 @@ """Test the Advantage Air Cover Platform.""" -from json import loads +from unittest.mock import AsyncMock -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -17,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_ac_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Cover Zone Entity @@ -65,12 +44,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -78,13 +53,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z01"]["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -92,12 +62,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -105,12 +71,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test controlling multiple Cover Zone Entity await hass.services.async_call( @@ -124,9 +86,9 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert len(mock_update.mock_calls) == 2 + mock_update.reset_mock() + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -138,27 +100,18 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + assert len(mock_update.mock_calls) == 2 async def test_things_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Blind 1 Entity @@ -171,7 +124,7 @@ async def test_things_cover( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-200" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( COVER_DOMAIN, @@ -179,13 +132,8 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -193,10 +141,4 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index 01f6d809a49..80de9019715 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -1,28 +1,24 @@ """Test the Advantage Air Diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator async def test_select_async_setup_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + mock_get: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index c665d038878..21cadbc4b3d 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -1,22 +1,17 @@ """Test the Advantage Air Initialization.""" +from unittest.mock import AsyncMock + +from advantage_air import ApiError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_get -async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> None: """Test a successful setup entry and unload.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.LOADED @@ -25,15 +20,9 @@ async def test_async_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_async_setup_entry_failure( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - - entry = await add_mock_config(hass) + with patch_get(side_effect=ApiError): + entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 0e27b8aec73..4d21781772d 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -15,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_LIGHT_URL, - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test light setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_LIGHT_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Light Entity @@ -62,13 +43,9 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -76,13 +53,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Dimmable Light Entity entity_id = "light.light_b" @@ -98,13 +70,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -112,32 +79,17 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things lights.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -149,7 +101,7 @@ async def test_things_light( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-204" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -157,13 +109,8 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -171,10 +118,4 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 553c2e60180..3367595d777 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -1,5 +1,7 @@ """Test the Advantage Air Select Platform.""" -from json import loads + + +from unittest.mock import AsyncMock from homeassistant.components.select import ( ATTR_OPTION, @@ -10,37 +12,19 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_select_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test MyZone Select Entity entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) @@ -57,10 +41,4 @@ async def test_select_async_setup_entry( {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Zone 3"}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["myZone"] == 3 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index e4fab12291d..a7483e680b3 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,6 +1,6 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from json import loads +from unittest.mock import AsyncMock from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( @@ -13,37 +13,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test sensor platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First TimeToOn Sensor entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) @@ -55,19 +39,15 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-timetoOn" value = 20 + await hass.services.async_call( ADVANTAGE_AIR_DOMAIN, ADVANTAGE_AIR_SERVICE_SET_TIME_TO, {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOn"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First TimeToOff Sensor entity_id = "sensor.myzone_time_to_off" @@ -86,13 +66,8 @@ async def test_sensor_platform( {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOff"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First Zone Vent Sensor entity_id = "sensor.myzone_zone_open_with_sensor_vent" @@ -139,6 +114,7 @@ async def test_sensor_platform( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -147,6 +123,7 @@ async def test_sensor_platform( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 99e4c645e71..a703f7edefd 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,34 +12,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_cover_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test switch platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -60,12 +41,8 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -73,30 +50,17 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_switch( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things switches.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -108,7 +72,7 @@ async def test_things_switch( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-205" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( SWITCH_DOMAIN, @@ -116,13 +80,8 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -130,10 +89,4 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 985641b923b..cb180d73f39 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -1,25 +1,26 @@ """Test the Advantage Air Update Platform.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import load_json_object_fixture + +TEST_NEEDS_UPDATE = load_json_object_fixture("needsUpdate.json", DOMAIN) async def test_update_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test update platform.""" - - aioclient_mock.get( - TEST_SYSTEM_URL, - text=load_fixture("advantage_air/needsUpdate.json"), - ) + mock_get.return_value = TEST_NEEDS_UPDATE await add_mock_config(hass) entity_id = "update.testname_app" From 617b045727d1da15b241874184d61fd56ab466d7 Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 02:34:01 -0500 Subject: [PATCH 256/927] Fix SSLCipherList typing error in IMAP coordinator (#105362) --- homeassistant/components/imap/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 34286ce49fa..5591980b2f1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -62,7 +62,7 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) if data.get(CONF_VERIFY_SSL, True): - ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + ssl_context = client_context(ssl_cipher_list=SSLCipherList(ssl_cipher_list)) else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) From 906aa14b43e7ce88663ffdce46fe6aaa6480f417 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 9 Dec 2023 04:01:40 -0400 Subject: [PATCH 257/927] Bump pyschlage to 2023.12.0 (#105349) Co-authored-by: J. Nick Koston --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 1eb7cb2ab0f..e14a5bc706e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.11.0"] + "requirements": ["pyschlage==2023.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 192d753a8c8..054a3e300e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2042,7 +2042,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc85c9bc58a..b0dd713b162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1547,7 +1547,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 From f567bf6dfe0e17e3d926063b28054ba054d7a609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Sat, 9 Dec 2023 05:45:40 -0300 Subject: [PATCH 258/927] Sun WEG integration (#88272) * feat(sunweg): initial support * chore: removed commented out code * chore: removed warning * fix: set never_resets for total sensors * test: some tests * fix(sunweg): default plantid type * fix(sunweg): return first plant id * test(sunweg): improved code coverage * chore(sunweg): missing FlowResult return type * chore(sunweg): removed unused strings * perf(sunweg): using only one api instance * chore(sunweg): removed uneeded atribute * refact(sunweg): small refactoring * refact(sunweg): typing * chore(sunweg): comments * chore(sunweg): bump version * chore(sunweg): bump lib version * test(sunweg): different mocking and coverage * test: fixed setup component parameter * feat: dynamic metrics * fix(sunweg): ruff * fix(sunweg): mypy * refact(sunweg): codereview suggestions * chore(sunweg): removed unused string * chore(sunweg): typehint and code formatting --- CODEOWNERS | 2 + homeassistant/components/sunweg/__init__.py | 193 ++++++++++++++++++ .../components/sunweg/config_flow.py | 74 +++++++ homeassistant/components/sunweg/const.py | 12 ++ homeassistant/components/sunweg/manifest.json | 10 + homeassistant/components/sunweg/sensor.py | 177 ++++++++++++++++ .../sunweg/sensor_types/__init__.py | 1 + .../sunweg/sensor_types/inverter.py | 69 +++++++ .../components/sunweg/sensor_types/phase.py | 26 +++ .../sensor_types/sensor_entity_description.py | 23 +++ .../components/sunweg/sensor_types/string.py | 26 +++ .../components/sunweg/sensor_types/total.py | 54 +++++ homeassistant/components/sunweg/strings.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sunweg/__init__.py | 1 + tests/components/sunweg/common.py | 63 ++++++ tests/components/sunweg/test_config_flow.py | 135 ++++++++++++ tests/components/sunweg/test_init.py | 146 +++++++++++++ 21 files changed, 1050 insertions(+) create mode 100644 homeassistant/components/sunweg/__init__.py create mode 100644 homeassistant/components/sunweg/config_flow.py create mode 100644 homeassistant/components/sunweg/const.py create mode 100644 homeassistant/components/sunweg/manifest.json create mode 100644 homeassistant/components/sunweg/sensor.py create mode 100644 homeassistant/components/sunweg/sensor_types/__init__.py create mode 100644 homeassistant/components/sunweg/sensor_types/inverter.py create mode 100644 homeassistant/components/sunweg/sensor_types/phase.py create mode 100644 homeassistant/components/sunweg/sensor_types/sensor_entity_description.py create mode 100644 homeassistant/components/sunweg/sensor_types/string.py create mode 100644 homeassistant/components/sunweg/sensor_types/total.py create mode 100644 homeassistant/components/sunweg/strings.json create mode 100644 tests/components/sunweg/__init__.py create mode 100644 tests/components/sunweg/common.py create mode 100644 tests/components/sunweg/test_config_flow.py create mode 100644 tests/components/sunweg/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index b5ae219bb1b..1fe8bf68e78 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1253,6 +1253,8 @@ build.json @home-assistant/supervisor /homeassistant/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig +/homeassistant/components/sunweg/ @rokam +/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py new file mode 100644 index 00000000000..f77633f4953 --- /dev/null +++ b/homeassistant/components/sunweg/__init__.py @@ -0,0 +1,193 @@ +"""The Sun WEG inverter sensor integration.""" +import datetime +import json +import logging + +from sunweg.api import APIHelper +from sunweg.plant import Plant + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType +from homeassistant.util import Throttle + +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + if not await hass.async_add_executor_job(api.authenticate): + _LOGGER.error("Username or Password may be incorrect!") + return False + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( + api, entry.data[CONF_PLANT_ID] + ) + 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.""" + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SunWEGData: + """The class for handling data retrieval.""" + + def __init__( + self, + api: APIHelper, + plant_id: int, + ) -> None: + """Initialize the probe.""" + + self.api = api + self.plant_id = plant_id + self.data: Plant = None + self.previous_values: dict = {} + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update probe data.""" + _LOGGER.debug("Updating data for plant %s", self.plant_id) + try: + self.data = self.api.plant(self.plant_id) + for inverter in self.data.inverters: + self.api.complete_inverter(inverter) + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from SunWEG server") + _LOGGER.debug("Finished updating data for plant %s", self.plant_id) + + def get_api_value( + self, + variable: str, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ): + """Retrieve from a Plant the desired variable value.""" + if device_type == "total": + return self.data.__dict__.get(variable) + + inverter_list = [i for i in self.data.inverters if i.id == inverter_id] + if len(inverter_list) == 0: + return None + inverter = inverter_list[0] + + if device_type == "inverter": + return inverter.__dict__.get(variable) + if device_type == "phase": + for phase in inverter.phases: + if phase.name == deep_name: + return phase.__dict__.get(variable) + elif device_type == "string": + for mppt in inverter.mppts: + for string in mppt.strings: + if string.name == deep_name: + return string.__dict__.get(variable) + return None + + def get_data( + self, + entity_description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> StateType | datetime.datetime: + """Get the data.""" + _LOGGER.debug( + "Data request for: %s", + entity_description.name, + ) + variable = entity_description.api_variable_key + previous_metric = entity_description.native_unit_of_measurement + api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) + previous_value = self.previous_values.get(variable) + return_value = api_value + if entity_description.api_variable_metric is not None: + entity_description.native_unit_of_measurement = self.get_api_value( + entity_description.api_variable_metric, + device_type, + inverter_id, + deep_name, + ) + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + and previous_metric == entity_description.native_unit_of_measurement + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + # Scenarios: + # 1 - System has a genuine 0 value when it it first commissioned: + # - will return 0 until a non-zero value is registered + # 2 - System has been running fine but temporarily resets to 0 briefly + # at midnight: + # - will return the previous value + # 3 - HA is restarted during the midnight 'outage' - Not handled: + # - Previous value will not exist meaning 0 will be returned + # - This is an edge case that would be better handled by looking + # up the previous value of the entity from the recorder + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py new file mode 100644 index 00000000000..cd24a4722e9 --- /dev/null +++ b/homeassistant/components/sunweg/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Sun WEG integration.""" +from sunweg.api import APIHelper +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PLANT_ID, DOMAIN + + +class SunWEGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialise sun weg server flow.""" + self.api: APIHelper = None + self.data: dict = {} + + @callback + def _async_show_user_form(self, errors=None) -> FlowResult: + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + # Initialise the library with the username & password + self.api = APIHelper(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + login_response = await self.hass.async_add_executor_job(self.api.authenticate) + + if not login_response: + return self._async_show_user_form({"base": "invalid_auth"}) + + # Store authentication info + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None) -> FlowResult: + """Handle adding a "plant" to Home Assistant.""" + plant_list = await self.hass.async_add_executor_job(self.api.listPlants) + + if len(plant_list) == 0: + return self.async_abort(reason="no_plants") + + plants = {plant.id: plant.name for plant in plant_list} + + if user_input is None and len(plant_list) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_list) == 1: + user_input = {CONF_PLANT_ID: plant_list[0].id} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py new file mode 100644 index 00000000000..12ecfb3849c --- /dev/null +++ b/homeassistant/components/sunweg/const.py @@ -0,0 +1,12 @@ +"""Define constants for the Sun WEG component.""" +from homeassistant.const import Platform + +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = 0 + +DEFAULT_NAME = "Sun WEG" + +DOMAIN = "sunweg" + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json new file mode 100644 index 00000000000..271a16236d3 --- /dev/null +++ b/homeassistant/components/sunweg/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunweg", + "name": "Sun WEG", + "codeowners": ["@rokam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunweg/", + "iot_class": "cloud_polling", + "loggers": ["sunweg"], + "requirements": ["sunweg==2.0.0"] +} diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py new file mode 100644 index 00000000000..157595219e8 --- /dev/null +++ b/homeassistant/components/sunweg/sensor.py @@ -0,0 +1,177 @@ +"""Read status of SunWEG inverters.""" +from __future__ import annotations + +import datetime +import logging +from types import MappingProxyType +from typing import Any + +from sunweg.api import APIHelper +from sunweg.device import Inverter +from sunweg.plant import Plant + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import SunWEGData +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.phase import PHASE_SENSOR_TYPES +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription +from .sensor_types.string import STRING_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +def get_device_list( + api: APIHelper, config: MappingProxyType[str, Any] +) -> tuple[list[Inverter], int]: + """Retrieve the device list for the selected plant.""" + plant_id = int(config[CONF_PLANT_ID]) + + if plant_id == DEFAULT_PLANT_ID: + plant_info: list[Plant] = api.listPlants() + plant_id = plant_info[0].id + + devices: list[Inverter] = [] + # Get a list of devices for specified plant to add sensors for. + for inverter in api.plant(plant_id).inverters: + api.complete_inverter(inverter) + devices.append(inverter) + return (devices, plant_id) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SunWEG sensor.""" + name = config_entry.data[CONF_NAME] + + probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] + + devices, plant_id = await hass.async_add_executor_job( + get_device_list, probe.api, config_entry.data + ) + + entities = [ + SunWEGInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, + device_type="total", + ) + for description in TOTAL_SENSOR_TYPES + ] + + # Add sensors for each device in the specified plant. + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name}", + unique_id=f"{device.sn}-{description.key}", + description=description, + device_type="inverter", + inverter_id=device.id, + ) + for device in devices + for description in INVERTER_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {phase.name}", + unique_id=f"{device.sn}-{phase.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="phase", + deep_name=phase.name, + ) + for device in devices + for phase in device.phases + for description in PHASE_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {string.name}", + unique_id=f"{device.sn}-{string.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="string", + deep_name=string.name, + ) + for device in devices + for mppt in device.mppts + for string in mppt.strings + for description in STRING_SENSOR_TYPES + ] + ) + + async_add_entities(entities, True) + + +class SunWEGInverter(SensorEntity): + """Representation of a SunWEG Sensor.""" + + entity_description: SunWEGSensorEntityDescription + + def __init__( + self, + probe: SunWEGData, + name: str, + unique_id: str, + description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> None: + """Initialize a sensor.""" + self.probe = probe + self.entity_description = description + self.device_type = device_type + self.inverter_id = inverter_id + self.deep_name = deep_name + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = ( + description.icon if description.icon is not None else "mdi:solar-power" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(probe.plant_id))}, + manufacturer="SunWEG", + name=name, + ) + + @property + def native_value( + self, + ) -> StateType | datetime.datetime: + """Return the state of the sensor.""" + return self.probe.get_data( + self.entity_description, + device_type=self.device_type, + inverter_id=self.inverter_id, + deep_name=self.deep_name, + ) + + def update(self) -> None: + """Get the latest data from the Sun WEG API and updates the state.""" + self.probe.update() diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py new file mode 100644 index 00000000000..f370fddd16b --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Sun WEG systems.""" diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py new file mode 100644 index 00000000000..abb7e224836 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -0,0 +1,69 @@ +"""SunWEG Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import SunWEGSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_variable_key="_total_energy", + api_variable_metric="_total_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_variable_key="_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_variable_key="_power", + api_variable_metric="_power_metric", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_variable_key="_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:temperature-celsius", + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_power_factor", + name="Power Factor", + api_variable_key="_power_factor", + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor_types/phase.py new file mode 100644 index 00000000000..ca6b9374e0d --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/phase.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the Phase type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py new file mode 100644 index 00000000000..c3a00df6b6f --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -0,0 +1,23 @@ +"""Sensor Entity Description for the SunWEG integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class SunWEGRequiredKeysMixin: + """Mixin for required keys.""" + + api_variable_key: str + + +@dataclass +class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): + """Describes SunWEG sensor entity.""" + + api_variable_metric: str | None = None + previous_value_drop_threshold: float | None = None + never_resets: bool = False + icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor_types/string.py new file mode 100644 index 00000000000..d3ee0a43c21 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/string.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the String type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py new file mode 100644 index 00000000000..da874be7a24 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -0,0 +1,54 @@ +"""SunWEG Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfEnergy, UnitOfPower + +from .sensor_entity_description import SunWEGSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_variable_key="_saving", + icon="mdi:cash", + native_unit_of_measurement="R$", + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SunWEGSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_variable_key="_total_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + SunWEGSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_variable_key="_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="kwh_per_kwp", + name="kWh por kWp", + api_variable_key="_kwh_per_kwp", + ), + SunWEGSensorEntityDescription( + key="last_update", + name="Last Update", + api_variable_key="_last_update", + device_class=SensorDeviceClass.DATE, + ), +) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json new file mode 100644 index 00000000000..3a910e62940 --- /dev/null +++ b/homeassistant/components/sunweg/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Sun WEG information" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b620f9018b..164aa2acdd2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -472,6 +472,7 @@ FLOWS = { "stookwijzer", "subaru", "sun", + "sunweg", "surepetcare", "switchbee", "switchbot", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9fc28e59ee2..89c5ee6a80d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5545,6 +5545,12 @@ "config_flow": true, "iot_class": "calculated" }, + "sunweg": { + "name": "Sun WEG", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "supervisord": { "name": "Supervisord", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 054a3e300e4..5e3e6a1224a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2552,6 +2552,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0dd713b162..956d7079981 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1913,6 +1913,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py new file mode 100644 index 00000000000..1453483a3fd --- /dev/null +++ b/tests/components/sunweg/__init__.py @@ -0,0 +1 @@ +"""Tests for the sunweg component.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py new file mode 100644 index 00000000000..075af21f74b --- /dev/null +++ b/tests/components/sunweg/common.py @@ -0,0 +1,63 @@ +"""Common functions needed to setup tests for Sun WEG.""" +from datetime import datetime + +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +SUNWEG_PLANT_RESPONSE = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), +) + +SUNWEG_INVERTER_RESPONSE = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", +) + +SUNWEG_PHASE_RESPONSE = Phase("PhaseA", 120.0, 3.2, 0, 0) + +SUNWEG_MPPT_RESPONSE = MPPT("MPPT1") + +SUNWEG_STRING_RESPONSE = String("STR1", 450.3, 23.4, 0) + +SUNWEG_LOGIN_RESPONSE = True + +SUNWEG_MOCK_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_PLANT_ID: 0, + CONF_NAME: "Name", + }, +) diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py new file mode 100644 index 00000000000..64d7816f077 --- /dev/null +++ b/tests/components/sunweg/test_config_flow.py @@ -0,0 +1,135 @@ +"""Tests for the Sun WEG server config flow.""" +from copy import deepcopy +from unittest.mock import patch + +from sunweg.api import APIHelper + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .common import FIXTURE_USER_INPUT, SUNWEG_LOGIN_RESPONSE, SUNWEG_PLANT_RESPONSE + +from tests.common import MockConfigEntry + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_incorrect_login(hass: HomeAssistant) -> None: + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + plant_list = [deepcopy(SUNWEG_PLANT_RESPONSE), deepcopy(SUNWEG_PLANT_RESPONSE)] + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=plant_list): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: 123456} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_one_plant_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_existing_plant_configured(hass: HomeAssistant) -> None: + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py new file mode 100644 index 00000000000..216a0254e66 --- /dev/null +++ b/tests/components/sunweg/test_init.py @@ -0,0 +1,146 @@ +"""Tests for the Sun WEG init.""" + +from copy import deepcopy +import json +from unittest.mock import MagicMock, patch + +from sunweg.api import APIHelper +from sunweg.device import MPPT, Inverter +from sunweg.plant import Plant + +from homeassistant.components.sunweg import SunWEGData +from homeassistant.components.sunweg.const import DOMAIN +from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( + SunWEGSensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + SUNWEG_INVERTER_RESPONSE, + SUNWEG_LOGIN_RESPONSE, + SUNWEG_MOCK_ENTRY, + SUNWEG_MPPT_RESPONSE, + SUNWEG_PHASE_RESPONSE, + SUNWEG_PLANT_RESPONSE, + SUNWEG_STRING_RESPONSE, +) + + +async def test_methods(hass: HomeAssistant) -> None: + """Test methods.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + mppt: MPPT = deepcopy(SUNWEG_MPPT_RESPONSE) + mppt.strings.append(SUNWEG_STRING_RESPONSE) + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + inverter.phases.append(SUNWEG_PHASE_RESPONSE) + inverter.mppts.append(mppt) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( + APIHelper, "plant", return_value=plant + ), patch.object( + APIHelper, "inverter", return_value=inverter + ), patch.object( + APIHelper, "complete_inverter" + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mock_entry.entry_id) + + +async def test_setup_wrongpass(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object(APIHelper, "authenticate", return_value=False): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_sunwegdata_update_exception() -> None: + """Test SunWEGData exception on update.""" + api = MagicMock() + api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) + data = SunWEGData(api, 0) + data.update() + assert data.data is None + + +async def test_sunwegdata_update_success() -> None: + """Test SunWEGData success on update.""" + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + api = MagicMock() + api.plant = MagicMock(return_value=plant) + api.complete_inverter = MagicMock() + data = SunWEGData(api, 0) + data.update() + assert data.data.id == plant.id + assert data.data.name == plant.name + assert data.data.kwh_per_kwp == plant.kwh_per_kwp + assert data.data.last_update == plant.last_update + assert data.data.performance_rate == plant.performance_rate + assert data.data.saving == plant.saving + assert len(data.data.inverters) == 1 + + +async def test_sunwegdata_get_api_value_none() -> None: + """Test SunWEGData none return on get_api_value.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.data = deepcopy(SUNWEG_PLANT_RESPONSE) + assert data.get_api_value("variable", "inverter", 0, "deep_name") is None + data.data.inverters.append(deepcopy(SUNWEG_INVERTER_RESPONSE)) + assert data.get_api_value("variable", "invalid type", 21255, "deep_name") is None + + +async def test_sunwegdata_get_data_drop_threshold() -> None: + """Test SunWEGData get_data with drop threshold.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.previous_value_drop_threshold = 0.1 + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.91 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) + + +async def test_sunwegdata_get_data_never_reset() -> None: + """Test SunWEGData get_data with never reset.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.never_resets = True + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) From 4d708f1931f1599cdb8860b8d353c1fc2ac955ea Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 09:47:28 +0100 Subject: [PATCH 259/927] Migrate template tests to use freezegun (#105341) --- tests/components/template/test_button.py | 22 ++++---- tests/components/template/test_trigger.py | 64 +++++++++++------------ 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index bfdb9352767..ece568eee49 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,7 @@ """The tests for the Template button platform.""" import datetime as dt -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -59,7 +60,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("button") == [] -async def test_all_optional_config(hass: HomeAssistant, calls) -> None: +async def test_all_optional_config( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -98,14 +101,13 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: ) now = dt.datetime.now(dt.UTC) - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) assert len(calls) == 1 assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 84fdadfec0d..af010c57e2e 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -1,8 +1,8 @@ """The tests for the Template automation.""" from datetime import timedelta from unittest import mock -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -803,56 +803,56 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N assert mock_logger.error.called -async def test_if_fires_on_time_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ utcnow().minute % 2 == 0 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() - assert len(calls) == 0 + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ utcnow().minute % 2 == 0 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 # Trigger once (match template) first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) second_time = start_time.replace(minute=4, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=second_time): - async_fire_time_changed(hass, second_time) - await hass.async_block_till_done() + freezer.move_to(second_time) + async_fire_time_changed(hass, second_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (do not match template) third_time = start_time.replace(minute=5, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=third_time): - async_fire_time_changed(hass, third_time) - await hass.async_block_till_done() + freezer.move_to(third_time) + async_fire_time_changed(hass, third_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) forth_time = start_time.replace(minute=8, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=forth_time): - async_fire_time_changed(hass, forth_time) - await hass.async_block_till_done() + freezer.move_to(forth_time) + async_fire_time_changed(hass, forth_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 2 From cc85e89cf2a022a23c12a7720802dfb699c3825b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Dec 2023 23:19:08 -1000 Subject: [PATCH 260/927] Make network fixture scoped to session to speed up tests (#105353) --- tests/conftest.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4d0e2565164..777b2073847 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.network.models import Adapter, IPv4ConfiguredAddress from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, @@ -1096,21 +1095,18 @@ async def mqtt_mock_entry( yield _setup_mqtt_entry -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None, None, None]: """Mock network.""" - mock_adapter = Adapter( - name="eth0", - index=0, - enabled=True, - auto=True, - default=True, - ipv4=[IPv4ConfiguredAddress(address="10.10.10.10", network_prefix=24)], - ipv6=[], - ) with patch( - "homeassistant.components.network.network.async_load_adapters", - return_value=[mock_adapter], + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], ): yield From 2d02cdcd0daf8afe4afa86c1004431ebf018c524 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 10:19:42 +0100 Subject: [PATCH 261/927] Migrate gree tests to use freezegun (#105340) --- tests/components/gree/test_bridge.py | 8 ++-- tests/components/gree/test_climate.py | 57 ++++++++++++++------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index b13544fd3f7..f40ab6525d4 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -1,7 +1,7 @@ """Tests for gree component.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN @@ -24,7 +24,7 @@ def mock_now(): async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" mock_device_1 = build_device_mock( @@ -58,8 +58,8 @@ async def test_discovery_after_setup( device.side_effect = [mock_device_1, mock_device_2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 82ad75b5d28..f5af1f403c3 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest @@ -115,7 +116,7 @@ async def test_discovery_setup_connection_error( async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" MockDevice1 = build_device_mock( @@ -142,8 +143,8 @@ async def test_discovery_after_setup( device.side_effect = [MockDevice1, MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -151,7 +152,7 @@ async def test_discovery_after_setup( async def test_discovery_add_device_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after initial setup.""" MockDevice1 = build_device_mock( @@ -178,8 +179,8 @@ async def test_discovery_add_device_after_setup( device.side_effect = [MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -187,7 +188,7 @@ async def test_discovery_add_device_after_setup( async def test_discovery_device_bind_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after a late device bind.""" MockDevice1 = build_device_mock( @@ -212,15 +213,17 @@ async def test_discovery_device_bind_after_setup( MockDevice1.update_state.side_effect = None next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state != STATE_UNAVAILABLE -async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) -> None: +async def test_update_connection_failure( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now +) -> None: """Testing update hvac connection failure exception.""" device().update_state.side_effect = [ DEFAULT_MOCK, @@ -231,8 +234,8 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) await async_setup_gree(hass) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # First update to make the device available @@ -241,13 +244,13 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() next_update = mock_now + timedelta(minutes=15) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # Then two more update failures to make the device unavailable @@ -257,7 +260,7 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) async def test_update_connection_failure_recovery( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection failure recovery.""" device().update_state.side_effect = [ @@ -270,8 +273,8 @@ async def test_update_connection_failure_recovery( # First update becomes unavailable next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -280,8 +283,8 @@ async def test_update_connection_failure_recovery( # Second update restores the connection next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -290,7 +293,7 @@ async def test_update_connection_failure_recovery( async def test_update_unhandled_exception( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection unhandled response exception.""" device().update_state.side_effect = [DEFAULT_MOCK, Exception] @@ -302,8 +305,8 @@ async def test_update_unhandled_exception( assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -312,15 +315,15 @@ async def test_update_unhandled_exception( async def test_send_command_device_timeout( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test for sending power on command to the device with a device timeout.""" await async_setup_gree(hass) # First update to make the device available next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) From d54c36307a839578e086775d1eed1c6be445d7c7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 9 Dec 2023 20:53:47 +1000 Subject: [PATCH 262/927] Fix the ruff-format error (#105376) --- tests/components/sunweg/test_init.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 216a0254e66..fd9ab5ce895 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -43,9 +43,7 @@ async def test_methods(hass: HomeAssistant) -> None: APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( APIHelper, "plant", return_value=plant - ), patch.object( - APIHelper, "inverter", return_value=inverter - ), patch.object( + ), patch.object(APIHelper, "inverter", return_value=inverter), patch.object( APIHelper, "complete_inverter" ): assert await async_setup_component(hass, DOMAIN, mock_entry.data) From 1eaf208450e43551485380d54828d51663f99b7b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Dec 2023 12:29:23 +0100 Subject: [PATCH 263/927] Update freezegun to 1.3.1 (#105377) --- pyproject.toml | 3 --- requirements_test.txt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d7764864168..9e9e8de4916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -455,9 +455,6 @@ filterwarnings = [ "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", # https://github.com/kiorky/croniter/issues/49 - v1.4.1 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", - # https://github.com/spulec/freezegun/issues/508 - v1.2.2 - # https://github.com/spulec/freezegun/pull/511 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 diff --git a/requirements_test.txt b/requirements_test.txt index d880fecaca5..f8918dc73f4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==3.0.1 coverage==7.3.2 -freezegun==1.2.2 +freezegun==1.3.1 mock-open==1.4.0 mypy==1.7.1 pre-commit==3.5.0 From b5785003a3737b387e5fd2906f42386dd4ff18d0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 12:55:40 +0100 Subject: [PATCH 264/927] Migrate unifi tests to use freezegun (#105343) --- tests/components/unifi/test_device_tracker.py | 22 +++++++++---------- tests/components/unifi/test_sensor.py | 16 ++++++++------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index abe12a1e243..5a12b99d10b 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,9 +1,8 @@ """The tests for the UniFi Network device tracker platform.""" from datetime import timedelta -from unittest.mock import patch from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -72,7 +71,7 @@ async def test_tracked_wireless_clients( # Change time to mark client as away new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -293,6 +292,7 @@ async def test_tracked_wireless_clients_event_source( async def test_tracked_devices( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -351,9 +351,9 @@ async def test_tracked_devices( # Change of time can mark device not_home outside of expected reporting interval new_time = dt_util.utcnow() + timedelta(seconds=90) - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME @@ -712,7 +712,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -740,7 +740,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -759,7 +759,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -808,7 +808,7 @@ async def test_wireless_client_go_wired_issue( # Pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def test_option_ignore_wired_bug( # pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f4366b98fc3..854d136f3dd 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -429,6 +430,7 @@ async def test_bandwidth_sensors( async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, entity_registry_enabled_by_default: None, initial_uptime, @@ -450,13 +452,13 @@ async def test_uptime_sensors( } now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.now", return_value=now): - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[uptime_client], - ) + freezer.move_to(now) + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[uptime_client], + ) assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 From 25586f9efd26e24aee0fbd0ad8ae18e8fd9c579f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 9 Dec 2023 13:06:18 +0100 Subject: [PATCH 265/927] Add data descriptions to Reolink (#105298) --- homeassistant/components/reolink/strings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5a27f0e38cb..04b46323e11 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -12,7 +12,11 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", + "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } }, "reauth_confirm": { @@ -38,6 +42,9 @@ "init": { "data": { "protocol": "Protocol" + }, + "data_description": { + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } From f37f40c33818d178d3b734be00be9bd1d4d99b25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Dec 2023 13:49:32 +0100 Subject: [PATCH 266/927] Fix preset modes error in Smartthings (#105375) --- homeassistant/components/smartthings/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b97ca06a471..f07c293939a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -528,10 +528,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes = self._device.status.attributes[ + supported_modes: list | None = self._device.status.attributes[ "supportedAcOptionalMode" ].value - if WINDFREE in supported_modes: + if supported_modes and WINDFREE in supported_modes: return [WINDFREE] return None From 35b733fa2c851983d701c0deebea206c03c70f3c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 9 Dec 2023 16:12:05 +0100 Subject: [PATCH 267/927] Bump `aioshelly` to version 7.0.0 (#105384) * Remove get_rpc_device_sleep_period() function * Bump aioshelly version to 7.0.0 * Remove firmware compatibility check from BLE scanner * Remove firmware compatibility check from light transition * Update default fw ver * Use LightEntityFeature in tests --- homeassistant/components/shelly/__init__.py | 5 +- .../components/shelly/config_flow.py | 18 +----- homeassistant/components/shelly/const.py | 6 -- .../components/shelly/coordinator.py | 10 ---- homeassistant/components/shelly/light.py | 9 +-- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/strings.json | 3 - homeassistant/components/shelly/utils.py | 9 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../shelly/bluetooth/test_scanner.py | 13 ----- tests/components/shelly/conftest.py | 30 ++-------- tests/components/shelly/test_config_flow.py | 56 ------------------- tests/components/shelly/test_light.py | 7 ++- 14 files changed, 18 insertions(+), 154 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b29fdcc6d19..553d32f8e48 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -49,7 +49,6 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, ) @@ -266,9 +265,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( - device.config - ) or get_rpc_device_wakeup_period(device.status) + data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 6cde265bc25..98233d27b22 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,7 +12,6 @@ from aioshelly.exceptions import ( InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice -from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -24,7 +23,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DOMAIN, @@ -32,14 +30,13 @@ from .const import ( MODEL_WALL_DISPLAY, BLEScannerMode, ) -from .coordinator import async_reconnect_soon, get_entry_data +from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, get_info_auth, get_info_gen, get_model_name, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, @@ -78,9 +75,7 @@ async def validate_input( ) await rpc_device.shutdown() - sleep_period = get_rpc_device_sleep_period( - rpc_device.config - ) or get_rpc_device_wakeup_period(rpc_device.status) + sleep_period = get_rpc_device_wakeup_period(rpc_device.status) return { "title": rpc_device.name, @@ -383,15 +378,6 @@ class OptionsFlowHandler(OptionsFlow): ) -> FlowResult: """Handle options flow.""" if user_input is not None: - entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] - if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( - not entry_data.rpc - or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION - ): - return self.async_abort( - reason="ble_unsupported", - description_placeholders={"ble_min_version": BLE_MIN_VERSION}, - ) return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a90aba8db62..ca1c450c9fa 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -22,7 +22,6 @@ from aioshelly.const import ( MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, ) -from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -33,9 +32,6 @@ CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# Firmware 1.11.0 release date, this firmware supports light transition -LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 - # max light transition time in milliseconds MAX_TRANSITION_TIME: Final = 5000 @@ -187,8 +183,6 @@ ENTRY_RELOAD_COOLDOWN = 60 SHELLY_GAS_MODELS = [MODEL_GAS] -BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") - CONF_BLE_SCANNER_MODE = "ble_scanner_mode" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d1f9d6943bf..a7659ecc392 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -13,7 +13,6 @@ from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -33,7 +32,6 @@ from .const import ( ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, @@ -587,14 +585,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return - if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: - LOGGER.error( - "BLE not supported on device %s with firmware %s; upgrade to %s", - self.name, - self.device.version, - BLE_MIN_VERSION, - ) - return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 829a60b3a9e..2dfc5b497b1 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -24,11 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DUAL_MODE_LIGHT_MODELS, - FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, - LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LOGGER, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, @@ -155,12 +153,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) - if ( - match is not None - and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE - ): - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b8185712d31..b56ce07bc30 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.1.0"], + "requirements": ["aioshelly==7.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9230ae605e0..330dd246c47 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -71,9 +71,6 @@ "ble_scanner_mode": "Bluetooth scanner mode" } } - }, - "abort": { - "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." } }, "selector": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6b5c59f28db..b53e3153a09 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -267,15 +267,6 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds -def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices. - - sys.sleep.wakeup_period value is deprecated and not available in Shelly - firmware 1.0.0 or later. - """ - return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) - - def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: """Return the device wakeup period in seconds or 0 for non sleeping devices.""" return cast(int, status["sys"].get("wakeup_period", 0)) diff --git a/requirements_all.txt b/requirements_all.txt index 5e3e6a1224a..1dccc1a5551 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 956d7079981..57ec5a22df9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -329,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index bd44782f928..9fe5f77f00c 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -108,19 +108,6 @@ async def test_scanner_ignores_wrong_version_and_logs( assert "Unsupported BLE scan result version: 0" in caplog.text -async def test_scanner_minimum_firmware_log_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture -) -> None: - """Test scanner log error if device firmware incompatible.""" - monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") - await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} - ) - assert mock_rpc_device.initialized is True - - assert "BLE not supported on device" in caplog.text - - async def test_scanner_warns_on_corrupt_event( hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 6eb74e26dcb..8a863a852f5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -157,14 +157,13 @@ MOCK_CONFIG = { "sys": { "ui_data": {}, "device": {"name": "Test name"}, - "wakeup_period": 0, }, } MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, - "fw": "20201124-092854/v1.9.0@57ac4ad8", + "fw": "20210715-092854/v1.11.0@57ac4ad8", "num_outputs": 2, } @@ -174,8 +173,8 @@ MOCK_SHELLY_RPC = { "mac": MOCK_MAC, "model": MODEL_PLUS_2PM, "gen": 2, - "fw_id": "20220830-130540/0.11.0-gfa1bc37", - "ver": "0.11.0", + "fw_id": "20230803-130540/1.0.0-gfa1bc37", + "ver": "1.0.0", "app": "Plus2PM", "auth_en": False, "auth_domain": None, @@ -290,7 +289,7 @@ async def mock_block_device(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, - version="0.10.0", + version="1.11.0", status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, @@ -314,7 +313,7 @@ def _mock_rpc_device(version: str | None = None): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY_RPC, - version=version or "0.12.0", + version=version or "1.0.0", hostname="test-host", status=MOCK_STATUS_RPC, firmware_version="some fw string", @@ -324,23 +323,6 @@ def _mock_rpc_device(version: str | None = None): return device -@pytest.fixture -async def mock_pre_ble_rpc_device(): - """Mock rpc (Gen2, Websocket) device pre BLE.""" - with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: - - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) - - device = _mock_rpc_device("0.11.0") - rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - - yield rpc_device_mock.return_value - - @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -363,7 +345,7 @@ async def mock_rpc_device(): {}, RpcUpdateType.DISCONNECTED ) - device = _mock_rpc_device("0.12.0") + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) rpc_device_mock.return_value.mock_update = Mock(side_effect=update) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 9482080a1a3..c7ac472ada4 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -967,62 +967,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_options_flow_pre_ble_device( - hass: HomeAssistant, mock_pre_ble_rpc_device -) -> None: - """Test setting ble options for gen2 devices with pre ble firmware.""" - entry = await init_integration(hass, 2) - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - await hass.config_entries.async_unload(entry.entry_id) - - async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index e3aea966230..77b65ad3bb5 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -134,7 +134,10 @@ async def test_block_device_rgb_bulb( ColorMode.COLOR_TEMP, ColorMode.RGB, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + ) assert len(attributes[ATTR_EFFECT_LIST]) == 4 assert attributes[ATTR_EFFECT] == "Off" @@ -232,7 +235,7 @@ async def test_block_device_white_bulb( assert state.state == STATE_ON assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() From c96a58893495ad493a667d0b4782b7b95118f0fa Mon Sep 17 00:00:00 2001 From: mkmer Date: Sat, 9 Dec 2023 13:18:59 -0500 Subject: [PATCH 268/927] Fix service missing key in Blink (#105387) * fix update service refactor service yaml * Remove leftover target --- homeassistant/components/blink/services.py | 7 +++- homeassistant/components/blink/services.yaml | 44 +++++++++++++------- homeassistant/components/blink/strings.json | 28 ++++++++++++- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 12ac0d3b859..dae2f0ad951 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -25,6 +25,11 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator +SERVICE_UPDATE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + } +) SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), @@ -152,7 +157,7 @@ def setup_services(hass: HomeAssistant) -> None: # Register all the above services service_mapping = [ - (blink_refresh, SERVICE_REFRESH, None), + (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), ( async_handle_save_video_service, SERVICE_SAVE_VIDEO, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index f6420e7f004..aaecde64353 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,18 +1,28 @@ # Describes the format for available Blink services blink_update: + fields: + device_id: + required: true + selector: + device: + integration: blink + trigger_camera: - target: - entity: - integration: blink - domain: camera + fields: + device_id: + required: true + selector: + device: + integration: blink save_video: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -25,11 +35,12 @@ save_video: text: save_recent_clips: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -42,11 +53,12 @@ save_recent_clips: text: send_pin: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink pin: example: "abc123" selector: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index f47f72acb9c..fc0450dc8ea 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -57,11 +57,23 @@ "services": { "blink_update": { "name": "Update", - "description": "Forces a refresh." + "description": "Forces a refresh.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image." + "description": "Requests camera to take new image.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "save_video": { "name": "Save video", @@ -74,6 +86,10 @@ "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -88,6 +104,10 @@ "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -98,6 +118,10 @@ "pin": { "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } } From a0bf170fb45a5dbb5c0336f8a11c5771802aaa69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Dec 2023 08:41:37 -1000 Subject: [PATCH 269/927] Avoid ffmpeg subprocess for many component tests (#105354) --- tests/components/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ebcc864b4b..adf79a2ef96 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -91,3 +91,12 @@ def tts_mutagen_mock_fixture(): from tests.components.tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() + + +@pytest.fixture(scope="session", autouse=True) +def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: + """Prevent ffmpeg from creating a subprocess.""" + with patch( + "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" + ): + yield From a090bcb8a55f434b2ab3ce3537f82d03a6925ca4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 21:35:52 +0100 Subject: [PATCH 270/927] Migrate time_date tests to use freezegun (#105409) --- tests/components/time_date/test_sensor.py | 52 +++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 96c7edf422b..f9ef8a7cfe9 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,29 +1,30 @@ """The tests for time_date sensor platform.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -async def test_intervals(hass: HomeAssistant) -> None: +async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test timing intervals of sensors.""" device = time_date.TimeDateSensor(hass, "time") now = dt_util.utc_from_timestamp(45.5) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(60) device = time_date.TimeDateSensor(hass, "beat") now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") device = time_date.TimeDateSensor(hass, "date_time") now = dt_util.utc_from_timestamp(1495068899) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(1495068900) now = dt_util.utcnow() @@ -102,14 +103,16 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -async def test_timezone_intervals(hass: HomeAssistant) -> None: +async def test_timezone_intervals( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") device = time_date.TimeDateSensor(hass, "date") now = dt_util.utc_from_timestamp(50000) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() # start of local day in EST was 18000.0 # so the second day was 18000 + 86400 assert next_time.timestamp() == 104400 @@ -117,43 +120,40 @@ async def test_timezone_intervals(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Edmonton") now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") device = time_date.TimeDateSensor(hass, "date") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") # Entering DST hass.config.set_time_zone("Europe/Prague") now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") # Leaving DST now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") -@patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), -) async def test_timezone_intervals_empty_parameter( - utcnow_mock, hass: HomeAssistant + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test get_interval() without parameters.""" + freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) hass.config.set_time_zone("America/Edmonton") device = time_date.TimeDateSensor(hass, "date") next_time = device.get_next_interval() From 885410bcfcf5d96e0a3c0e648bbd9a0d1644873f Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 16:30:12 -0500 Subject: [PATCH 271/927] Prevent duplicate default SSLContext instances (#105348) Co-authored-by: J. Nick Koston --- homeassistant/util/ssl.py | 31 +++++++++++++++++++------------ tests/util/test_ssl.py | 9 +++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 2b503716063..6bfbec88a33 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -61,16 +61,11 @@ SSL_CIPHER_LISTS = { @cache -def create_no_verify_ssl_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate. +def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: + # This is a copy of aiohttp's create_default_context() function, with the + # ssl verify turned off. + # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - This is a copy of aiohttp's create_default_context() function, with the - ssl verify turned off. - - https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE @@ -84,12 +79,16 @@ def create_no_verify_ssl_context( return sslcontext -@cache -def client_context( +def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context for making requests.""" + """Return an SSL context that does not verify the server certificate.""" + return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) + + +@cache +def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,6 +103,14 @@ def client_context( return sslcontext +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + + return _client_context(ssl_cipher_list=ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() _DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 4d43859cc44..4a88e061cbc 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -51,3 +51,12 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_called_with( SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] ) + + +def test_ssl_context_caching() -> None: + """Test that SSLContext instances are cached correctly.""" + + assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT) + assert create_no_verify_ssl_context() is create_no_verify_ssl_context( + SSLCipherList.PYTHON_DEFAULT + ) From 4e1677e3f0c96472fdbfe56a2596868aac08a8ad Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 9 Dec 2023 17:33:31 -0500 Subject: [PATCH 272/927] Remove zwave_js device on device reset (#104291) * Reload zwave_js config entry on device reset * remove device * Just remove the device and don't reload * revert change to notification message * Assert device is no longer there --- homeassistant/components/zwave_js/__init__.py | 9 ++++-- tests/components/zwave_js/test_init.py | 29 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a8b3d300e3b..ccadc452bc7 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -449,7 +449,10 @@ class ControllerEvents: "remove_entity" ), ) - elif reason == RemoveNodeReason.RESET: + # We don't want to remove the device so we can keep the user customizations + return + + if reason == RemoveNodeReason.RESET: device_name = device.name_by_user or device.name or f"Node {node.node_id}" identifier = get_network_identifier_for_notification( self.hass, self.config_entry, self.driver_events.driver.controller @@ -471,8 +474,8 @@ class ControllerEvents: "Device Was Factory Reset!", f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) - else: - self.remove_device(device) + + self.remove_device(device) @callback def async_on_identify(self, event: dict) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index bf015a70676..75a7397cc4e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1650,6 +1650,7 @@ async def test_factory_reset_node( hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration ) -> None: """Test when a node is removed because it was reset.""" + dev_reg = dr.async_get(hass) # One config entry scenario remove_event = Event( type="node removed", @@ -1670,15 +1671,25 @@ async def test_factory_reset_node( assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) + await hass.async_block_till_done() + assert not dev_reg.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match @@ -1686,16 +1697,24 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( - "network `Mock Title`, with the home ID `3245146787`." + "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) From 327016eaebb78eb7f4bbf082393b0efd0fa0ae36 Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 17:45:33 -0500 Subject: [PATCH 273/927] Accept HTTP 200 through 206 as success for RESTful Switch (#105358) --- homeassistant/components/rest/switch.py | 4 ++-- tests/components/rest/test_switch.py | 30 +++++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 102bb024924..f80143e2f9e 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -171,7 +171,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = True else: _LOGGER.error( @@ -186,7 +186,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = False else: _LOGGER.error( diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 7ded4fb0aed..6224d98f694 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -53,6 +53,22 @@ RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE +@pytest.fixture( + params=( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + HTTPStatus.NON_AUTHORITATIVE_INFORMATION, + HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.PARTIAL_CONTENT, + ) +) +def http_success_code(request: pytest.FixtureRequest) -> HTTPStatus: + """Fixture providing different successful HTTP response code.""" + return request.param + + async def test_setup_missing_config( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -262,11 +278,14 @@ async def test_is_on_before_update(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_on_success(hass: HomeAssistant) -> None: +async def test_turn_on_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_on.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -320,11 +339,14 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_off_success(hass: HomeAssistant) -> None: +async def test_turn_off_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_off.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, From 64a5271a51c76a8450d334fcd5920f4ef285314e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 10 Dec 2023 08:46:32 +1000 Subject: [PATCH 274/927] Add Tessie Integration (#104684) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + homeassistant/components/tessie/__init__.py | 60 ++++ .../components/tessie/config_flow.py | 56 ++++ homeassistant/components/tessie/const.py | 21 ++ .../components/tessie/coordinator.py | 84 +++++ homeassistant/components/tessie/entity.py | 45 +++ homeassistant/components/tessie/manifest.json | 10 + homeassistant/components/tessie/sensor.py | 225 ++++++++++++++ homeassistant/components/tessie/strings.json | 92 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tessie/__init__.py | 1 + tests/components/tessie/common.py | 55 ++++ tests/components/tessie/fixtures/asleep.json | 1 + tests/components/tessie/fixtures/online.json | 276 +++++++++++++++++ .../components/tessie/fixtures/vehicles.json | 292 ++++++++++++++++++ tests/components/tessie/test_config_flow.py | 139 +++++++++ tests/components/tessie/test_coordinator.py | 92 ++++++ tests/components/tessie/test_init.py | 30 ++ tests/components/tessie/test_sensor.py | 24 ++ 22 files changed, 1518 insertions(+) create mode 100644 homeassistant/components/tessie/__init__.py create mode 100644 homeassistant/components/tessie/config_flow.py create mode 100644 homeassistant/components/tessie/const.py create mode 100644 homeassistant/components/tessie/coordinator.py create mode 100644 homeassistant/components/tessie/entity.py create mode 100644 homeassistant/components/tessie/manifest.json create mode 100644 homeassistant/components/tessie/sensor.py create mode 100644 homeassistant/components/tessie/strings.json create mode 100644 tests/components/tessie/__init__.py create mode 100644 tests/components/tessie/common.py create mode 100644 tests/components/tessie/fixtures/asleep.json create mode 100644 tests/components/tessie/fixtures/online.json create mode 100644 tests/components/tessie/fixtures/vehicles.json create mode 100644 tests/components/tessie/test_config_flow.py create mode 100644 tests/components/tessie/test_coordinator.py create mode 100644 tests/components/tessie/test_init.py create mode 100644 tests/components/tessie/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1fe8bf68e78..dad0d51ad79 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1303,6 +1303,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tessie/ @Bre77 +/tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py new file mode 100644 index 00000000000..e792780e873 --- /dev/null +++ b/homeassistant/components/tessie/__init__.py @@ -0,0 +1,60 @@ +"""Tessie integration.""" +import logging + +from aiohttp import ClientError, ClientResponseError +from tessie_api import get_state_of_all_vehicles + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tessie config.""" + api_key = entry.data[CONF_ACCESS_TOKEN] + + try: + vehicles = await get_state_of_all_vehicles( + session=async_get_clientsession(hass), + api_key=api_key, + only_active=True, + ) + except ClientResponseError as ex: + # Reauth will go here + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex) + return False + except ClientError as e: + raise ConfigEntryNotReady from e + + coordinators = [ + TessieDataUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) + for vehicle in vehicles["results"] + if vehicle["last_state"] is not None + ] + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tessie Config.""" + 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/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py new file mode 100644 index 00000000000..c286f43c8b3 --- /dev/null +++ b/homeassistant/components/tessie/config_flow.py @@ -0,0 +1,56 @@ +"""Config Flow for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from tessie_api import get_state_of_all_vehicles +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Tessie API connection.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input and CONF_ACCESS_TOKEN in user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Tessie", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py new file mode 100644 index 00000000000..dad9ba2345f --- /dev/null +++ b/homeassistant/components/tessie/const.py @@ -0,0 +1,21 @@ +"""Constants used by Tessie integration.""" +from __future__ import annotations + +from enum import StrEnum + +DOMAIN = "tessie" + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + ONLINE = "online" + OFFLINE = "offline" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py new file mode 100644 index 00000000000..7a1efb985ee --- /dev/null +++ b/homeassistant/components/tessie/coordinator.py @@ -0,0 +1,84 @@ +"""Tessie Data Coordinator.""" +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +from aiohttp import ClientResponseError +from tessie_api import get_state + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import TessieStatus + +# This matches the update interval Tessie performs server side +TESSIE_SYNC_INTERVAL = 10 + +_LOGGER = logging.getLogger(__name__) + + +class TessieDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Tessie API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie", + update_method=self.async_update_data, + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = self._flattern(data) + self.did_first_update = False + + async def async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Tessie API.""" + try: + vehicle = await get_state( + session=self.session, + api_key=self.api_key, + vin=self.vin, + use_cache=self.did_first_update, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.REQUEST_TIMEOUT: + # Vehicle is offline, only update state and dont throw error + self.data["state"] = TessieStatus.OFFLINE + return self.data + # Reauth will go here + raise e + + self.did_first_update = True + if vehicle["state"] == TessieStatus.ONLINE: + # Vehicle is online, all data is fresh + return self._flattern(vehicle) + + # Vehicle is asleep, only update state + self.data["state"] = vehicle["state"] + return self.data + + def _flattern( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flattern the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}-{key}" + if isinstance(value, dict): + result.update(self._flattern(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py new file mode 100644 index 00000000000..4a14522a64c --- /dev/null +++ b/homeassistant/components/tessie/entity.py @@ -0,0 +1,45 @@ +"""Tessie parent entity class.""" + + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS +from .coordinator import TessieDataUpdateCoordinator + + +class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): + """Parent class for Tessie Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie entity.""" + super().__init__(coordinator) + self.vin = coordinator.vin + self.key = key + + car_type = coordinator.data["vehicle_config-car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{self.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=coordinator.data["display_name"], + model=MODELS.get(car_type, car_type), + sw_version=coordinator.data["vehicle_state-car_version"], + hw_version=coordinator.data["vehicle_config-driver_assist"], + ) + + @property + def value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data[self.key] diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json new file mode 100644 index 00000000000..52fc8dd5be1 --- /dev/null +++ b/homeassistant/components/tessie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tessie", + "name": "Tessie", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tessie", + "iot_class": "cloud_polling", + "loggers": ["tessie"], + "requirements": ["tessie-api==0.0.9"] +} diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py new file mode 100644 index 00000000000..1941d8ba162 --- /dev/null +++ b/homeassistant/components/tessie/sensor.py @@ -0,0 +1,225 @@ +"""Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN, TessieStatus +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class TessieSensorEntityDescription(SensorEntityDescription): + """Describes Tessie Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + +DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="state", + options=[status.value for status in TessieStatus], + device_class=SensorDeviceClass.ENUM, + ), + TessieSensorEntityDescription( + key="charge_state-usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TessieSensorEntityDescription( + key="charge_state-charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state-charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TessieSensorEntityDescription( + key="charge_state-charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="drive_state-speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TessieSensorEntityDescription( + key="drive_state-power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state-shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TessieSensorEntityDescription( + key="vehicle_state-odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSensorEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TessieSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie metric sensors.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json new file mode 100644 index 00000000000..2069e46cecc --- /dev/null +++ b/homeassistant/components/tessie/strings.json @@ -0,0 +1,92 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + } + } + }, + "entity": { + "sensor": { + "state": { + "name": "Status", + "state": { + "online": "Online", + "asleep": "Asleep", + "offline": "Offline" + } + }, + "charge_state-usable_battery_level": { + "name": "Battery Level" + }, + "charge_state-charge_energy_added": { + "name": "Charge Energy Added" + }, + "charge_state-charger_power": { + "name": "Charger Power" + }, + "charge_state-charger_voltage": { + "name": "Charger Voltage" + }, + "charge_state-charger_actual_current": { + "name": "Charger Current" + }, + "charge_state-charge_rate": { + "name": "Charge Rate" + }, + "charge_state-battery_range": { + "name": "Battery Range" + }, + "drive_state-speed": { + "name": "Speed" + }, + "drive_state-power": { + "name": "Power" + }, + "drive_state-shift_state": { + "name": "Shift State", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state-odometer": { + "name": "Odometer" + }, + "vehicle_state-tpms_pressure_fl": { + "name": "Tyre Pressure Front Left" + }, + "vehicle_state-tpms_pressure_fr": { + "name": "Tyre Pressure Front Right" + }, + "vehicle_state-tpms_pressure_rl": { + "name": "Tyre Pressure Rear Left" + }, + "vehicle_state-tpms_pressure_rr": { + "name": "Tyre Pressure Rear Right" + }, + "climate_state-inside_temp": { + "name": "Inside Temperature" + }, + "climate_state-outside_temp": { + "name": "Outside Temperature" + }, + "climate_state-driver_temp_setting": { + "name": "Driver Temperature Setting" + }, + "climate_state-passenger_temp_setting": { + "name": "Passenger Temperature Setting" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 164aa2acdd2..975bfc60688 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -490,6 +490,7 @@ FLOWS = { "tautulli", "tellduslive", "tesla_wall_connector", + "tessie", "thermobeacon", "thermopro", "thread", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 89c5ee6a80d..33e2229eb2e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5802,6 +5802,12 @@ } } }, + "tessie": { + "name": "Tessie", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tfiac": { "name": "Tfiac", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1dccc1a5551..d05699120cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,6 +2603,9 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.tensorflow # tf-models-official==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57ec5a22df9..afe214608ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1943,6 +1943,9 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.thermobeacon thermobeacon-ble==0.6.0 diff --git a/tests/components/tessie/__init__.py b/tests/components/tessie/__init__.py new file mode 100644 index 00000000000..df17fe027d9 --- /dev/null +++ b/tests/components/tessie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tessie integration.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py new file mode 100644 index 00000000000..572a687a6e5 --- /dev/null +++ b/tests/components/tessie/common.py @@ -0,0 +1,55 @@ +"""Tessie common helpers for tests.""" + +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo + +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) +TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) + +TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +TESSIE_URL = "https://api.tessie.com/" + +TEST_REQUEST_INFO = RequestInfo( + url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL +) + +ERROR_AUTH = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED +) +ERROR_TIMEOUT = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT +) +ERROR_UNKNOWN = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST +) +ERROR_CONNECTION = ClientConnectionError() + + +async def setup_platform(hass: HomeAssistant, side_effect=None): + """Set up the Tessie platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tessie.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/tessie/fixtures/asleep.json b/tests/components/tessie/fixtures/asleep.json new file mode 100644 index 00000000000..4f78efafcf1 --- /dev/null +++ b/tests/components/tessie/fixtures/asleep.json @@ -0,0 +1 @@ +{ "state": "asleep" } diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json new file mode 100644 index 00000000000..8fbab1ab948 --- /dev/null +++ b/tests/components/tessie/fixtures/online.json @@ -0,0 +1,276 @@ +{ + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" +} diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json new file mode 100644 index 00000000000..9cc833a1cb2 --- /dev/null +++ b/tests/components/tessie/fixtures/vehicles.json @@ -0,0 +1,292 @@ +{ + "results": [ + { + "vin": "VINVINVIN", + "is_active": true, + "is_archived_manually": false, + "last_charge_created_at": null, + "last_charge_updated_at": null, + "last_drive_created_at": null, + "last_drive_updated_at": null, + "last_idle_created_at": null, + "last_idle_updated_at": null, + "last_state": { + "id": 123456789, + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" + } + } + ] +} diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py new file mode 100644 index 00000000000..edf2f8914ae --- /dev/null +++ b/tests/components/tessie/test_config_flow.py @@ -0,0 +1,139 @@ +"""Test the Tessie config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_CONFIG, + TEST_STATE_OF_ALL_VEHICLES, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles, patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tessie" + assert result2["data"] == TEST_CONFIG + + +async def test_form_invalid_access_token(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_AUTH, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_invalid_response(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_UNKNOWN, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_network_issue(hass: HomeAssistant) -> None: + """Test network issues are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_CONNECTION, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py new file mode 100644 index 00000000000..43b6489c39d --- /dev/null +++ b/tests/components/tessie/test_coordinator.py @@ -0,0 +1,92 @@ +"""Test the Tessie sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.components.tessie.sensor import TessieStatus +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import ( + ERROR_CONNECTION, + ERROR_TIMEOUT, + ERROR_UNKNOWN, + TEST_VEHICLE_STATE_ASLEEP, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + ) as mock_get_state: + yield mock_get_state + + +async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles online vehciles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ONLINE + + +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles asleep vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ASLEEP + + +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles client errors.""" + + mock_get_state.side_effect = ERROR_UNKNOWN + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_TIMEOUT + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE + + +async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles connection errors.""" + + mock_get_state.side_effect = ERROR_CONNECTION + await setup_platform(hass) + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py new file mode 100644 index 00000000000..409ece97a24 --- /dev/null +++ b/tests/components/tessie/test_init.py @@ -0,0 +1,30 @@ +"""Test the Tessie init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_unknown_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_failure(hass: HomeAssistant) -> None: + """Test init with a network connection failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py new file mode 100644 index 00000000000..b9371032d0e --- /dev/null +++ b/tests/components/tessie/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the Tessie sensor platform.""" +from homeassistant.components.tessie.sensor import DESCRIPTIONS +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_sensors(hass: HomeAssistant) -> None: + """Tests that the sensors are correct.""" + + assert len(hass.states.async_all("sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) + + assert hass.states.get("sensor.test_battery_level").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] + ) + assert hass.states.get("sensor.test_charge_energy_added").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] + ) + assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN From a8148cea65454b79b44ab1c7da15d9b57d39f805 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 23:47:19 +0100 Subject: [PATCH 275/927] Migrate roku tests to use freezegun (#105418) --- tests/components/roku/test_media_player.py | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5d4568ce7ac..c186741aac9 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -153,6 +154,7 @@ async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, error: RokuError, ) -> None: """Test entity availability.""" @@ -160,23 +162,22 @@ async def test_availability( future = now + timedelta(minutes=1) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = error - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + freezer.move_to(future) + mock_roku.update.side_effect = error + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE future += timedelta(minutes=1) - - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = None - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE + freezer.move_to(future) + mock_roku.update.side_effect = None + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE async def test_supported_features( From 7b32e4142ebd4be4c2085ead50f06066b2be39ef Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 10 Dec 2023 06:15:48 +0100 Subject: [PATCH 276/927] Make API init async in Minecraft Server (#105403) * Make api init async * Remove duplicate assignment of address and set server to None in constructor --- .../components/minecraft_server/__init__.py | 27 +++++---- .../components/minecraft_server/api.py | 39 +++++++++--- .../minecraft_server/config_flow.py | 6 +- .../minecraft_server/coordinator.py | 12 +++- .../snapshots/test_binary_sensor.ambr | 8 +-- .../snapshots/test_sensor.ambr | 60 +++++++++---------- .../minecraft_server/test_binary_sensor.py | 57 ++++++++++++++---- .../minecraft_server/test_config_flow.py | 8 +-- .../minecraft_server/test_diagnostics.py | 7 ++- .../components/minecraft_server/test_init.py | 34 ++++++++--- .../minecraft_server/test_sensor.py | 52 +++++++++++++--- 11 files changed, 219 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 4e5ab9290f0..0e2debda33e 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -30,13 +30,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - # Check and create API instance. + # Create API instance. + api = MinecraftServer( + hass, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. try: - api = await hass.async_add_executor_job( - MinecraftServer, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) + await api.async_initialize() except MinecraftServerAddressError as error: raise ConfigEntryError( f"Server address in configuration entry is invalid: {error}" @@ -102,9 +105,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_data = config_entry.data # Migrate config entry. + address = config_data[CONF_HOST] + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = config_data[CONF_HOST] - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() host_only_lookup_success = True except MinecraftServerAddressError as error: host_only_lookup_success = False @@ -114,9 +119,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if not host_only_lookup_success: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address, try again later: %s", diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 4ab7865f369..fc872d37bde 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -9,6 +9,8 @@ from dns.resolver import LifetimeTimeout from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) LOOKUP_TIMEOUT: float = 10 @@ -52,35 +54,51 @@ class MinecraftServerConnectionError(Exception): """Raised when no data can be fechted from the server.""" +class MinecraftServerNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + class MinecraftServer: """Minecraft Server wrapper class for 3rd party library mcstatus.""" - _server: BedrockServer | JavaServer + _server: BedrockServer | JavaServer | None - def __init__(self, server_type: MinecraftServerType, address: str) -> None: + def __init__( + self, hass: HomeAssistant, server_type: MinecraftServerType, address: str + ) -> None: """Initialize server instance.""" + self._server = None + self._hass = hass + self._server_type = server_type + self._address = address + + async def async_initialize(self) -> None: + """Perform async initialization of server instance.""" try: - if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + if self._server_type == MinecraftServerType.JAVA_EDITION: + self._server = await JavaServer.async_lookup(self._address) else: - self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + self._server = await self._hass.async_add_executor_job( + BedrockServer.lookup, self._address + ) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"Lookup of '{address}' failed: {self._get_error_message(error)}" + f"Lookup of '{self._address}' failed: {self._get_error_message(error)}" ) from error self._server.timeout = DATA_UPDATE_TIMEOUT - self._address = address _LOGGER.debug( - "%s server instance created with address '%s'", server_type, address + "%s server instance created with address '%s'", + self._server_type, + self._address, ) async def async_is_online(self) -> bool: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except MinecraftServerConnectionError: + except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): return False return True @@ -89,6 +107,9 @@ class MinecraftServer: """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" status_response: BedrockStatusResponse | JavaStatusResponse + if self._server is None: + raise MinecraftServerNotInitializedError() + try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f064a4ac1ef..045133421fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -35,10 +35,10 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. for server_type in MinecraftServerType: + api = MinecraftServer(self.hass, server_type, address) + try: - api = await self.hass.async_add_executor_job( - MinecraftServer, server_type, address - ) + await api.async_initialize() except MinecraftServerAddressError: pass else: diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f7a60318c64..e498375cafc 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -7,7 +7,12 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData +from .api import ( + MinecraftServer, + MinecraftServerConnectionError, + MinecraftServerData, + MinecraftServerNotInitializedError, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -32,5 +37,8 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Get updated data from the server.""" try: return await self._api.async_get_data() - except MinecraftServerConnectionError as error: + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: raise UpdateFailed(error) from error diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index ef03e36343b..2a62fea7f35 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -13,7 +13,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -27,7 +27,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -41,7 +41,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index fed0ae93c66..b0f77f27b80 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -13,7 +13,7 @@ 'state': '5', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -27,7 +27,7 @@ 'state': '3', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -41,7 +41,7 @@ 'state': '10', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -54,7 +54,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -67,7 +67,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -80,7 +80,7 @@ 'state': '123', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -93,7 +93,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -106,7 +106,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -119,7 +119,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -133,7 +133,7 @@ 'state': '5', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -152,7 +152,7 @@ 'state': '3', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -166,7 +166,7 @@ 'state': '10', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -179,7 +179,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -192,7 +192,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -205,7 +205,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -219,7 +219,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -233,7 +233,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -247,7 +247,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -260,7 +260,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -273,7 +273,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -286,7 +286,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -299,7 +299,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -312,7 +312,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -325,7 +325,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -339,7 +339,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -358,7 +358,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -372,7 +372,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -385,7 +385,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -398,7 +398,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 9fae35b113d..4db564bc143 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -22,16 +22,27 @@ from tests.common import async_fire_time_changed @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -41,7 +52,7 @@ async def test_binary_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -53,16 +64,27 @@ async def test_binary_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -73,7 +95,7 @@ async def test_binary_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -88,16 +110,27 @@ async def test_binary_sensor_update( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, freezer: FrozenDateTimeFactory, @@ -107,7 +140,7 @@ async def test_binary_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 785905492c1..2a0208f2251 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -41,7 +41,7 @@ async def test_address_validation_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -58,7 +58,7 @@ async def test_java_connection_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -95,7 +95,7 @@ async def test_java_connection(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,7 +138,7 @@ async def test_recovery(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index 6979325fa0c..80b5c91c1fb 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -42,9 +42,14 @@ async def test_config_entry_diagnostics( mock_config_entry = request.getfixturevalue(mock_config_entry) mock_config_entry.add_to_hass(hass) + if server.__name__ == "JavaServer": + lookup_function_name = "async_lookup" + else: + lookup_function_name = "lookup" + # Setup mock entry. with patch( - f"mcstatus.server.{server.__name__}.lookup", + f"mcstatus.server.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"mcstatus.server.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 018fdac542e..5b0d9509d69 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -122,7 +122,7 @@ async def test_setup_and_unload_entry( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,14 +138,14 @@ async def test_setup_and_unload_entry( assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_failure( +async def test_setup_entry_lookup_failure( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: - """Test failed entry setup.""" + """Test lookup failure in entry setup.""" java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): assert not await hass.config_entries.async_setup( @@ -156,6 +156,24 @@ async def test_setup_entry_failure( assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR +async def test_setup_entry_init_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test init failure in entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_initialize", + side_effect=None, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_not_ready( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: @@ -163,7 +181,7 @@ async def test_setup_entry_not_ready( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -196,7 +214,7 @@ async def test_entry_migration( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry @@ -258,7 +276,7 @@ async def test_entry_migration_host_only( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -293,7 +311,7 @@ async def test_entry_migration_v3_failure( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry ValueError, # async_migrate_entry diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index 006c735e034..7d599669d71 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -55,17 +55,25 @@ BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -75,6 +83,7 @@ async def test_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -85,7 +94,7 @@ async def test_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -98,17 +107,25 @@ async def test_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), @@ -118,6 +135,7 @@ async def test_sensor_disabled_by_default( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -127,7 +145,7 @@ async def test_sensor_disabled_by_default( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -141,17 +159,25 @@ async def test_sensor_disabled_by_default( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -161,6 +187,7 @@ async def test_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -172,7 +199,7 @@ async def test_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -189,17 +216,25 @@ async def test_sensor_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -209,6 +244,7 @@ async def test_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -219,7 +255,7 @@ async def test_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", From 1cc47c0553562d295ebec2d9671454824551947a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 10 Dec 2023 17:37:57 +1000 Subject: [PATCH 277/927] Add reauth to Tessie (#105419) * First pass at Tessie * Working POC * async_step_reauth * Config Flow tests * WIP * Add test requirement * correctly gen test requirements * 100% coverage * Remove remnants of copy paste * Add TPMS * Fix docstring * Remove redundant line * Fix some more copied docstrings * Grammar * Create reusable StrEnum * Streamline get * Add a couple more sensors * Removed need for a model * Move MODELS * Remove DOMAIN from config flow * Add translation strings * Remove unused parameter * Simplify error handling * Refactor coordinator to class * Add missing types * Add icon to shift state * Simplify setdefault Co-authored-by: J. Nick Koston * Use walrus for async_unload_platforms Co-authored-by: J. Nick Koston * Reformat entity init Co-authored-by: J. Nick Koston * Remove Unique ID * Better Config Flow Tests * Fix all remaining tests * Standardise docstring * Remove redudnant test * Set TessieDataUpdateCoordinator on entity * Correct some sensors * add error types * Make shift state a ENUM sensor * Add more sensors * Fix translation string * Add precision suggestions * Move session from init to coordinator * Add api_key type * Remove api_key parameter * Meta changes * Update TessieEntity and TessieSensor translations * Goodbye translation keys * bump tessie-api to 0.0.9 * Fix only_active to be True * Per vehicle coordinator * Rework coordinator * Fix coverage * WIP * The grand rework * Add comments * Use ENUM more * Add ENUM translations * Update homeassistant/components/tessie/sensor.py Co-authored-by: J. Nick Koston * Add entity_category * Remove reauth * Remove session * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Add property tag * Add error text * Complete config flow tests * Fix property and rename * Fix init test * Reworked coordinator tests * Add extra checks * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/tessie/coordinator.py Co-authored-by: J. Nick Koston * Apply suggestions from code review Co-authored-by: J. Nick Koston * Ruff fix * Update homeassistant/components/tessie/config_flow.py Co-authored-by: J. Nick Koston * Remove future ENUMs Co-authored-by: J. Nick Koston * Ruff fix * Update homeassistant/components/tessie/config_flow.py Co-authored-by: J. Nick Koston * Remove reauth and already configured strings * Lowercase sensor values for translation * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Fixed, before using lambda * Add value lambda * fix lambda * Fix config flow test * @bdraco fix for 500 errors * format * Add reauth * Reuse string in reauth * Ruff * remove redundant check * Improve error tests --------- Co-authored-by: J. Nick Koston --- homeassistant/components/tessie/__init__.py | 10 +- .../components/tessie/config_flow.py | 48 ++++++++- .../components/tessie/coordinator.py | 5 +- homeassistant/components/tessie/strings.json | 7 ++ tests/components/tessie/test_config_flow.py | 101 ++++++++++++++++++ tests/components/tessie/test_coordinator.py | 12 +++ tests/components/tessie/test_init.py | 9 +- 7 files changed, 185 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e792780e873..ac77c3cc09e 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,4 +1,5 @@ """Tessie integration.""" +from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError @@ -7,7 +8,7 @@ from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -28,9 +29,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key=api_key, only_active=True, ) - except ClientResponseError as ex: - # Reauth will go here - _LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) return False except ClientError as e: raise ConfigEntryNotReady from e diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index c286f43c8b3..4379a810309 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -10,6 +10,7 @@ from tessie_api import get_state_of_all_vehicles import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -24,12 +25,16 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: """Get configuration from the user.""" errors: dict[str, str] = {} - if user_input and CONF_ACCESS_TOKEN in user_input: + if user_input: try: await get_state_of_all_vehicles( session=async_get_clientsession(self.hass), @@ -54,3 +59,44 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=TESSIE_SCHEMA, errors=errors, ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle re-auth.""" + self._reauth_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: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 7a1efb985ee..7a2a8c71c56 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,6 +8,7 @@ from aiohttp import ClientResponseError from tessie_api import get_state 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 @@ -57,7 +58,9 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): # Vehicle is offline, only update state and dont throw error self.data["state"] = TessieStatus.OFFLINE return self.data - # Reauth will go here + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e raise e self.did_first_update = True diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 2069e46cecc..5d57075241c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -11,6 +11,13 @@ "access_token": "[%key:common::config_flow::data::access_token%]" }, "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index edf2f8914ae..d1977a13193 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -14,8 +16,21 @@ from .common import ( ERROR_UNKNOWN, TEST_CONFIG, TEST_STATE_OF_ALL_VEHICLES, + setup_platform, ) +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -137,3 +152,89 @@ async def test_form_network_issue(hass: HomeAssistant) -> None: TEST_CONFIG, ) assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {"base": "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 43b6489c39d..8fe92454c36 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .common import ( + ERROR_AUTH, ERROR_CONNECTION, ERROR_TIMEOUT, ERROR_UNKNOWN, @@ -81,6 +82,17 @@ async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: """Tests that the coordinator handles connection errors.""" diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 409ece97a24..8c12979b9d5 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform async def test_load_unload(hass: HomeAssistant) -> None: @@ -16,6 +16,13 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_unknown_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" From ff85d0c290707607b6bf0615d81a47cf907671eb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 10 Dec 2023 09:25:16 +0100 Subject: [PATCH 278/927] Migrate mqtt tests to use freezegun (#105414) --- tests/components/mqtt/test_common.py | 15 +++---- tests/components/mqtt/test_init.py | 67 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0664f6e8d6f..cb5ff53d7e9 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from unittest.mock import ANY, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol import yaml @@ -31,6 +32,7 @@ from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -1320,9 +1322,8 @@ async def help_test_entity_debug_info_max_messages( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(start_dt): for i in range(0, debug_info.STORED_MESSAGES + 1): async_fire_mqtt_message(hass, "test-topic", f"{i}") @@ -1396,7 +1397,7 @@ async def help_test_entity_debug_info_message( debug_info_data = debug_info.info_for_device(hass, device.id) - start_dt = datetime(2019, 1, 1, 0, 0, 0) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) if state_topic is not None: assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -1404,8 +1405,7 @@ async def help_test_entity_debug_info_message( "subscriptions" ] - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): async_fire_mqtt_message(hass, str(state_topic), state_payload) debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1426,8 +1426,7 @@ async def help_test_entity_debug_info_message( expected_transmissions = [] if service: # Trigger an outgoing MQTT message - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): if service: service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d31570548f0..98e2c9b71fe 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow from .test_common import help_all_subscribe_calls @@ -3256,6 +3258,7 @@ async def test_debug_info_wildcard( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3279,10 +3282,9 @@ async def test_debug_info_wildcard( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/abc", "123") + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -3304,6 +3306,7 @@ async def test_debug_info_filter_same( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3327,14 +3330,13 @@ async def test_debug_info_filter_same( "subscriptions" ] - dt1 = datetime(2019, 1, 1, 0, 0, 0) - dt2 = datetime(2019, 1, 1, 0, 0, 1) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = dt1 - async_fire_mqtt_message(hass, "sensor/abc", "123") - async_fire_mqtt_message(hass, "sensor/abc", "123") - dt_utcnow.return_value = dt2 - async_fire_mqtt_message(hass, "sensor/abc", "123") + dt1 = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + dt2 = datetime(2019, 1, 1, 0, 0, 1, tzinfo=dt_util.UTC) + freezer.move_to(dt1) + async_fire_mqtt_message(hass, "sensor/abc", "123") + async_fire_mqtt_message(hass, "sensor/abc", "123") + freezer.move_to(dt2) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3364,6 +3366,7 @@ async def test_debug_info_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3388,10 +3391,9 @@ async def test_debug_info_same_topic( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3408,16 +3410,16 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) async def test_debug_info_qos_retain( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3441,19 +3443,18 @@ async def test_debug_info_qos_retain( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - # simulate the first message was replayed from the broker with retained flag - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) - # simpulate someone else subscribed and retained messages were replayed - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 From 6a3c422d2f2313d9558f6d2cf1758092320b897c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 10 Dec 2023 13:38:10 +0100 Subject: [PATCH 279/927] Improve Amazon Alexa endpoint validation (#105287) * Improve Amazon Alexa endpoint validation * Add source comment --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/alexa/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 219553b3563..2a9637772b1 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings" CONF_SMART_HOME = "smart_home" DEFAULT_LOCALE = "en-US" +# Alexa Smart Home API send events gateway endpoints +# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints +VALID_ENDPOINTS = [ + "https://api.amazonalexa.com/v3/events", + "https://api.eu.amazonalexa.com/v3/events", + "https://api.fe.amazonalexa.com/v3/events", +] + + ALEXA_ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_DESCRIPTION): cv.string, @@ -46,7 +55,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema( SMART_HOME_SCHEMA = vol.Schema( { - vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( From 063ac53f01c9627b067e0fd8a82ace3725c330e5 Mon Sep 17 00:00:00 2001 From: Florian B Date: Sun, 10 Dec 2023 17:23:05 +0100 Subject: [PATCH 280/927] Fix adding/updating todo items with due date in CalDAV integration (#105435) * refactor: return date/datetime for due date * fix: explicitly set due date on vTODO component Using `set_due` automatically handles converting the Python-native date/datetime values to the correct representation required by RFC5545. * fix: fix tests with changed due date handling * fix: item.due may not be a str * refactor: keep local timezone of due datetime * refactor: reorder import statement To make ruff happy. * fix: fix false-positive mypy error --- homeassistant/components/caldav/todo.py | 10 +++++----- tests/components/caldav/test_todo.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 1bd24dc542a..b7089c3da65 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -98,10 +98,7 @@ def _to_ics_fields(item: TodoItem) -> dict[str, Any]: if status := item.status: item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: - if isinstance(due, datetime): - item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") - else: - item_data["due"] = due.strftime("%Y%m%d") + item_data["due"] = due if description := item.description: item_data["description"] = description return item_data @@ -162,7 +159,10 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - vtodo.update(**_to_ics_fields(item)) + updated_fields = _to_ics_fields(item) + if "due" in updated_fields: + todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] + vtodo.update(**updated_fields) try: await self.hass.async_add_executor_job( partial( diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 6e92f211463..a90529297be 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,4 +1,5 @@ """The tests for the webdav todo component.""" +from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, Mock @@ -200,12 +201,16 @@ async def test_supported_components( ), ( {"due_date": "2023-11-18"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)}, {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC), + }, {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), ( @@ -311,13 +316,13 @@ async def test_add_item_failure( ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE:20231118"], + ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], "1", {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], "1", {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), From a7155b154eb873ea43b0f52da3e21b19bf019a25 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Dec 2023 17:27:01 +0100 Subject: [PATCH 281/927] Fix alexa calling not featured cover services (#105444) * Fix alexa calls not supported cover services * Follow up comment and additional tests --- homeassistant/components/alexa/handlers.py | 19 +- tests/components/alexa/test_smart_home.py | 443 ++++++++++++++++----- 2 files changed, 354 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index f99b0231e4d..2796c10795b 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1304,13 +1304,14 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: service = cover.SERVICE_CLOSE_COVER - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: service = cover.SERVICE_OPEN_COVER else: service = cover.SERVICE_SET_COVER_POSITION @@ -1319,9 +1320,9 @@ async def async_api_set_range( # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION @@ -1332,13 +1333,11 @@ async def async_api_set_range( range_value = int(range_value) if range_value == 0: service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value else: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported and fan.FanEntityFeature.SET_SPEED: - service = fan.SERVICE_SET_PERCENTAGE - data[fan.ATTR_PERCENTAGE] = range_value - else: - service = fan.SERVICE_TURN_ON + service = fan.SERVICE_TURN_ON # Humidifier target humidity elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 7a1abe96110..0a5b1f79f72 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera -from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config @@ -1884,8 +1884,199 @@ async def test_group(hass: HomeAssistant) -> None: ) -async def test_cover_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("position", "position_attr_in_service_call", "supported_features", "service_call"), + [ + ( + 30, + 30, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.close_cover", + ), + ( + 99, + 99, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.open_cover", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ], + ids=[ + "position_30_open_close", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_cover_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and position using rangeController.""" + device = ( + "cover.test_range", + "open", + { + "friendly_name": "Test cover range", + "device_class": "blind", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Position", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="cover.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + +async def test_cover_position_range( + hass: HomeAssistant, +) -> None: + """Test cover discovery and position range using rangeController. + + Also tests an invalid cover position being handled correctly. + """ + device = ( "cover.test_range", "open", @@ -1969,59 +2160,6 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.set_cover_position", - hass, - payload={"rangeValue": 50}, - instance="cover.position", - ) - assert call.data["position"] == 50 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.close_cover", - hass, - payload={"rangeValue": 0}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValue": 100}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", @@ -3435,8 +3573,159 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert {"name": "humanPresenceDetectionState"} in properties["supported"] -async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "tilt_position", + "tilt_position_attr_in_service_call", + "supported_features", + "service_call", + ), + [ + ( + 30, + 30, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.close_cover_tilt", + ), + ( + 99, + 99, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.open_cover_tilt", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, + "cover.set_cover_tilt_position", + ), + ], + ids=[ + "tilt_position_30_open_close", + "tilt_position_0_open_close", + "tilt_position_99_open_close", + "tilt_position_100_open_close", + "tilt_position_0_no_open_close", + "tilt_position_60_no_open_close", + "tilt_position_100_no_open_close", + "tilt_position_0_no_close", + "tilt_position_100_no_open", + ], +) +async def test_cover_tilt_position( + hass: HomeAssistant, + tilt_position: int, + tilt_position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and tilt position using rangeController.""" + device = ( + "cover.test_tilt_range", + "open", + { + "friendly_name": "Test cover tilt range", + "device_class": "blind", + "supported_features": supported_features, + "tilt_position": tilt_position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_tilt_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover tilt range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.tilt" + + semantics = range_capability["semantics"] + assert semantics is not None + + action_mappings = semantics["actionMappings"] + assert action_mappings is not None + + state_mappings = semantics["stateMappings"] + assert state_mappings is not None + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_tilt_range", + service_call, + hass, + payload={"rangeValue": tilt_position}, + instance="cover.tilt", + ) + assert call.data.get("tilt_position") == tilt_position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == tilt_position + + +async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: + """Test cover discovery and tilt position range using rangeController. + + Also tests and invalid tilt position being handled correctly. + """ device = ( "cover.test_tilt_range", "open", @@ -3485,48 +3774,6 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: ) assert call.data["tilt_position"] == 50 - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.close_cover_tilt", - hass, - payload={"rangeValue": 0}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValue": 100}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", From 4752d37df70263979327ea6e8abdcf80414adca4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Dec 2023 14:09:58 -0800 Subject: [PATCH 282/927] Fix fitbit oauth reauth debug logging (#105450) --- homeassistant/components/fitbit/application_credentials.py | 5 ++++- tests/components/fitbit/test_init.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caf0384eca2..caa47351f45 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -60,7 +60,10 @@ class FitbitOAuth2Implementation(AuthImplementation): resp.raise_for_status() except aiohttp.ClientResponseError as err: if _LOGGER.isEnabledFor(logging.DEBUG): - error_body = await resp.text() if not session.closed else "" + try: + error_body = await resp.text() + except aiohttp.ClientError: + error_body = "" _LOGGER.debug( "Client response error status=%s, body=%s", err.status, error_body ) diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index b6bf75c1c69..3ed3695ff3d 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -107,18 +107,21 @@ async def test_token_refresh_success( @pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, status=HTTPStatus.UNAUTHORIZED, + closing=closing, ) assert not await integration_setup() From b5af987a180a213a74b9ffb3c05ab5acafe808d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 10 Dec 2023 23:16:06 +0100 Subject: [PATCH 283/927] Bump hatasmota to 0.8.0 (#105440) * Bump hatasmota to 0.8.0 * Keep devices with deep sleep support always available * Add tests --- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/mixins.py | 9 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/tasmota/test_binary_sensor.py | 29 +++++ tests/components/tasmota/test_common.py | 120 ++++++++++++++++++ tests/components/tasmota/test_cover.py | 36 ++++++ tests/components/tasmota/test_fan.py | 27 ++++ tests/components/tasmota/test_light.py | 27 ++++ tests/components/tasmota/test_sensor.py | 38 ++++++ tests/components/tasmota/test_switch.py | 25 ++++ 11 files changed, 313 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 42fc849a2cf..2ce81772774 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.3"] + "requirements": ["HATasmota==0.8.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 21030b8c14b..48dbe51fd67 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity): def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" - self._available = False super().__init__(**kwds) + if self._tasmota_entity.deep_sleep_enabled: + self._available = True + else: + self._available = False async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -122,6 +125,8 @@ class TasmotaAvailability(TasmotaEntity): async_subscribe_connection_status(self.hass, self.async_mqtt_connected) ) await super().async_added_to_hass() + if self._tasmota_entity.deep_sleep_enabled: + await self._tasmota_entity.poll_status() async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" @@ -135,6 +140,8 @@ class TasmotaAvailability(TasmotaEntity): if not self.hass.is_stopping: if not mqtt_connected(self.hass): self._available = False + elif self._tasmota_entity.deep_sleep_enabled: + self._available = True self.async_write_ha_state() @property diff --git a/requirements_all.txt b/requirements_all.txt index d05699120cd..0a3ddf78dc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afe214608ec..5e5b7bbe613 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2bfb4a9d5e2..d5f1e4d7101 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -31,6 +31,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -313,6 +315,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -323,6 +340,18 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability when deep sleep is enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index a184f650fae..1f414cb4e5a 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -4,6 +4,7 @@ import json from unittest.mock import ANY from hatasmota.const import ( + CONF_DEEP_SLEEP, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, @@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost( assert state.state != STATE_UNAVAILABLE +async def help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability after MQTT disconnection when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + # Device online + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Disconnected from MQTT server -> state changed to unavailable + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state == STATE_UNAVAILABLE + + # Reconnected to MQTT server -> state no longer unavailable + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Receive LWT again + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability( hass, mqtt_mock, @@ -236,6 +307,55 @@ async def help_test_availability( assert state.state == STATE_UNAVAILABLE +async def help_test_deep_sleep_availability( + hass, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability_discovery_update( hass, mqtt_mock, diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e2bdc8b2ca7..cae65521e21 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -663,6 +665,27 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.COVER, + config, + object_id="test_cover_1", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -676,6 +699,19 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 2a50e2d43b5..05e3151be2e 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -232,6 +234,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -243,6 +259,17 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 27b7bd1a82a..50f11fb7757 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1679,6 +1696,16 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 2f50a84ffdd..dc4820779a6 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -28,6 +28,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1238,6 +1260,22 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability( + hass, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 54d94b46fe8..1a16f372fc9 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -20,6 +20,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -158,6 +160,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -167,6 +183,15 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: From 58d9d0daa5b759424ba399c2437e8bbbda793b60 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sun, 10 Dec 2023 17:30:24 -0500 Subject: [PATCH 284/927] Add reauth to A. O. Smith integration (#105320) * Add reauth to A. O. Smith integration * Validate reauth uses the same email address * Only show password field during reauth --- .../components/aosmith/config_flow.py | 76 ++++++++++++--- .../components/aosmith/coordinator.py | 5 +- homeassistant/components/aosmith/strings.json | 10 +- tests/components/aosmith/conftest.py | 2 +- tests/components/aosmith/test_config_flow.py | 96 ++++++++++++++++++- 5 files changed, 170 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py index 4ee29897070..36a1c215d68 100644 --- a/homeassistant/components/aosmith/config_flow.py +++ b/homeassistant/components/aosmith/config_flow.py @@ -1,6 +1,7 @@ """Config flow for A. O. Smith integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -22,6 +23,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_email: str | None + + def __init__(self): + """Start the config flow.""" + self._reauth_email = None + + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate the credentials. Return an error string, or None if successful.""" + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient(email, password, session) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + return "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -32,30 +56,56 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - session = aiohttp_client.async_get_clientsession(self.hass) - client = AOSmithAPIClient( - user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - - try: - await client.get_devices() - except AOSmithInvalidCredentialsException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if error is None: return self.async_create_entry( title=user_input[CONF_EMAIL], data=user_input ) + errors["base"] = error + return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_EMAIL): str, + vol.Required(CONF_EMAIL, default=self._reauth_email): str, vol.Required(CONF_PASSWORD): str, } ), errors=errors, ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth if the user credentials have changed.""" + self._reauth_email = entry_data[CONF_EMAIL] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors: dict[str, str] = {} + if user_input is not None and self._reauth_email is not None: + email = self._reauth_email + password = user_input[CONF_PASSWORD] + entry_id = self.context["entry_id"] + + if entry := self.hass.config_entries.async_get_entry(entry_id): + error = await self._async_validate_credentials(email, password) + if error is None: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_EMAIL: self._reauth_email}, + errors=errors, + ) diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 80cf85bc59a..bdd144569dd 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -10,6 +10,7 @@ from py_aosmith import ( ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL @@ -29,7 +30,9 @@ class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Fetch latest data from API.""" try: devices = await self.client.get_devices() - except (AOSmithInvalidCredentialsException, AOSmithUnknownException) as err: + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err mode_pending = any( diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json index 157895e04f8..26de264bab9 100644 --- a/homeassistant/components/aosmith/strings.json +++ b/homeassistant/components/aosmith/strings.json @@ -7,6 +7,13 @@ "password": "[%key:common::config_flow::data::password%]" }, "description": "Please enter your A. O. Smith credentials." + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +21,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 509e15024a9..f0ece65d56f 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -24,7 +24,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data=FIXTURE_USER_INPUT, - unique_id="unique_id", + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], ) diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py index ff09f23ccbb..5d3e986e05e 100644 --- a/tests/components/aosmith/test_config_flow.py +++ b/tests/components/aosmith/test_config_flow.py @@ -1,15 +1,18 @@ """Test the A. O. Smith config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from py_aosmith import AOSmithInvalidCredentialsException import pytest from homeassistant import config_entries -from homeassistant.components.aosmith.const import DOMAIN -from homeassistant.const import CONF_EMAIL +from homeassistant.components.aosmith.const import DOMAIN, REGULAR_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.aosmith.conftest import FIXTURE_USER_INPUT @@ -82,3 +85,90 @@ async def test_form_exception( assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] assert result3["data"] == FIXTURE_USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_flow_retry( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works with retry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # First attempt at reauth - authentication fails again + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithInvalidCredentialsException("Authentication error"), + ): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Second attempt at reauth - authentication succeeds + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result3 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" From 77c722630ed8a19b9f29afa11c1a9b53fb2b1210 Mon Sep 17 00:00:00 2001 From: Jan Schneider Date: Sun, 10 Dec 2023 23:59:54 +0100 Subject: [PATCH 285/927] Check if heat area exists when setting up valve opening and battery sensors in moehlenhoff alpha2 (#105437) Check whether the referenced heat area exists when setting up valve opening and battery sensors --- homeassistant/components/moehlenhoff_alpha2/binary_sensor.py | 1 + homeassistant/components/moehlenhoff_alpha2/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 8acc88d8314..5cdca72fa55 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -27,6 +27,7 @@ async def async_setup_entry( Alpha2IODeviceBatterySensor(coordinator, io_device_id) for io_device_id, io_device in coordinator.data["io_devices"].items() if io_device["_HEATAREA_ID"] + and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"] ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index e41c6b041f6..2c2e44f451d 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id) for heat_control_id, heat_control in coordinator.data["heat_controls"].items() if heat_control["INUSE"] - and heat_control["_HEATAREA_ID"] + and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"] and heat_control.get("ACTOR_PERCENT") is not None ) From 72c6eb888593cb8c0882d94130cb4823e7c0a045 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Dec 2023 15:46:27 -0800 Subject: [PATCH 286/927] Bump ical to 6.1.1 (#105462) --- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d7b16ee3bef..f5a24e07b0c 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==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 4c3a8e10a62..335a89eab3c 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a3ddf78dc7..eb038bc6c2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1064,7 +1064,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e5b7bbe613..44ec0de5322 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 From c634e3f0ca3baf725beb165b851b537d65e20e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 20:47:53 -1000 Subject: [PATCH 287/927] Bump zeroconf to 0.128.4 (#105465) * Bump zeroconf to 0.128.3 significant bug fixes changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.128.0...0.128.3 * .4 --- 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 8351212f0b8..6738431b304 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.128.0"] + "requirements": ["zeroconf==0.128.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a44f93324b..2f1373b61d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.128.0 +zeroconf==0.128.4 # 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 eb038bc6c2b..eea1c6243be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2832,7 +2832,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.128.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ec0de5322..fdce9d3c554 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2127,7 +2127,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.128.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 From c89c2f939263327f1683b7d8f534b382efe54bd5 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Sun, 10 Dec 2023 23:40:13 -0800 Subject: [PATCH 288/927] Bump pylitejet to v0.6.0 (#105472) --- homeassistant/components/litejet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 136880257ce..8525bb9ff17 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.5.0"] + "requirements": ["pylitejet==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index eea1c6243be..97e0f899ad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1872,7 +1872,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdce9d3c554..184f0eb8cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1416,7 +1416,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9 From fbfe434e8baf88e23d30c34aa3ad57b3e4fe0128 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Dec 2023 09:09:23 +0100 Subject: [PATCH 289/927] Migrate tag & tts tests to use freezegun (#105411) --- tests/components/tag/test_event.py | 20 ++++++++++------ tests/components/tag/test_init.py | 11 +++++---- tests/components/tts/test_init.py | 38 +++++++++++++++--------------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py index 7112a0cda4f..0338ed504d7 100644 --- a/tests/components/tag/test_event.py +++ b/tests/components/tag/test_event.py @@ -1,6 +1,6 @@ """Tests for the tag component.""" -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, EVENT_TAG_SCANNED, async_scan_tag @@ -40,7 +40,10 @@ def storage_setup_named_tag( async def test_named_tag_scanned_event( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_named_tag + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup_named_tag, ) -> None: """Test scanning named tag triggering event.""" assert await storage_setup_named_tag() @@ -50,8 +53,8 @@ async def test_named_tag_scanned_event( events = async_capture_events(hass, EVENT_TAG_SCANNED) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + freezer.move_to(now) + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) assert len(events) == 1 @@ -83,7 +86,10 @@ def storage_setup_unnamed_tag(hass, hass_storage): async def test_unnamed_tag_scanned_event( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_unnamed_tag + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup_unnamed_tag, ) -> None: """Test scanning named tag triggering event.""" assert await storage_setup_unnamed_tag() @@ -93,8 +99,8 @@ async def test_unnamed_tag_scanned_event( events = async_capture_events(hass, EVENT_TAG_SCANNED) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + freezer.move_to(now) + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) assert len(events) == 1 diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 5d54f31b13a..d7f77c0d2e2 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -1,6 +1,6 @@ """Tests for the tag component.""" -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, TAGS, async_scan_tag @@ -76,7 +76,10 @@ async def test_ws_update( async def test_tag_scanned( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup, ) -> None: """Test scanning tags.""" assert await storage_setup() @@ -93,8 +96,8 @@ async def test_tag_scanned( assert "test tag" in result now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, "new tag", "some_scanner") + freezer.move_to(now) + await async_scan_tag(hass, "new tag", "some_scanner") await client.send_json({"id": 7, "type": f"{DOMAIN}/list"}) resp = await client.receive_json() diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 5be56edbc32..990d8d273ed 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ffmpeg, tts @@ -78,6 +79,7 @@ async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MockTTSEntity, + freezer: FrozenDateTimeFactory, ) -> None: """Test we can unload config entry.""" entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" @@ -93,26 +95,24 @@ async def test_config_entry_unload( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - tts.DOMAIN, - "speak", - { - ATTR_ENTITY_ID: entity_id, - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 + freezer.move_to(now) + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: entity_id, + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 - assert ( - await retrieve_media( - hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] - ) - == HTTPStatus.OK - ) - await hass.async_block_till_done() + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state is not None From 3e3f9cf09237bbd2e6c24069e8e51f80e0818c2c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 11 Dec 2023 10:29:50 +0100 Subject: [PATCH 290/927] Bump plugwise to v0.35.3 (#105442) --- homeassistant/components/plugwise/const.py | 19 ++++++++++++++++++- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 3 +-- homeassistant/components/plugwise/select.py | 3 +-- .../components/plugwise/strings.json | 5 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../all_data.json | 19 ++++++++++++------- .../anna_heatpump_heating/all_data.json | 2 +- .../fixtures/m_adam_cooling/all_data.json | 4 ++-- .../fixtures/m_adam_heating/all_data.json | 4 ++-- .../m_anna_heatpump_cooling/all_data.json | 2 +- .../m_anna_heatpump_idle/all_data.json | 2 +- .../plugwise/snapshots/test_diagnostics.ambr | 9 +++++++-- 14 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 34bb5c926ae..f5677c0b4a9 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Final +from typing import Final, Literal from homeassistant.const import Platform @@ -36,6 +36,23 @@ ZEROCONF_MAP: Final[dict[str, str]] = { "stretch": "Stretch", } +NumberType = Literal[ + "maximum_boiler_temperature", + "max_dhw_temperature", + "temperature_offset", +] + +SelectType = Literal[ + "select_dhw_mode", + "select_regulation_mode", + "select_schedule", +] +SelectOptionsType = Literal[ + "dhw_modes", + "regulation_modes", + "available_schedules", +] + # Default directives DEFAULT_MAX_TEMP: Final = 30 DEFAULT_MIN_TEMP: Final = 4 diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index bb2b428bf19..92923e98d2c 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.5"], + "requirements": ["plugwise==0.35.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 2c87edddf04..c21ecbd94c7 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import NumberType from homeassistant.components.number import ( NumberDeviceClass, @@ -18,7 +17,7 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, NumberType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index c12ca671554..eef873703c1 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import SelectOptionsType, SelectType from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,7 +12,7 @@ from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SelectOptionsType, SelectType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5348a1dc484..addd1ceadb1 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -108,7 +108,10 @@ } }, "select_schedule": { - "name": "Thermostat schedule" + "name": "Thermostat schedule", + "state": { + "off": "Off" + } } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 97e0f899ad5..6b75e99ca9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.5 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 184f0eb8cb5..08e7c060269 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.5 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 279fe6b8a43..f97182782e6 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -112,7 +112,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -251,7 +252,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", @@ -334,7 +336,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -344,7 +347,7 @@ "model": "Lisa", "name": "Zone Lisa Bios", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 67, "setpoint": 13.0, @@ -373,7 +376,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", @@ -383,7 +387,7 @@ "model": "Tom/Floor", "name": "CV Kraan Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 68, "setpoint": 5.5, @@ -414,7 +418,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 9ef93d63bdd..d655f95c79b 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 2e1063d14d3..7b570a6cf61 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -52,7 +52,7 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -102,7 +102,7 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 81d60bed9d4..57259047698 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -80,7 +80,7 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -124,7 +124,7 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 844eae4c2f7..92c95f6c5a9 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index f6be6f35188..be400b9bc98 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 29f23a137fb..c2bbea9418a 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -115,6 +115,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -260,6 +261,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', @@ -349,6 +351,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -364,7 +367,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 67, 'setpoint': 13.0, @@ -394,6 +397,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', @@ -409,7 +413,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, @@ -441,6 +445,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', From b4731674f8128b43117e0557733bb38960364019 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Dec 2023 10:42:16 +0100 Subject: [PATCH 291/927] Migrate octoprint tests to use freezegun (#105408) --- tests/components/octoprint/test_sensor.py | 42 +++++++++++------------ 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 2ba657c77d5..3d3efd04da0 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,6 +1,7 @@ """The tests for Octoptint binary sensor module.""" from datetime import UTC, datetime -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,7 +9,7 @@ from homeassistant.helpers import entity_registry as er from . import init_integration -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -22,11 +23,8 @@ async def test_sensors(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Printing", } - with patch( - "homeassistant.util.dt.utcnow", - return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC), - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -80,7 +78,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: +async def test_sensors_no_target_temp( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -89,10 +89,8 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer) entity_registry = er.async_get(hass) @@ -111,7 +109,9 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: assert entry.unique_id == "target tool1 temp-uuid" -async def test_sensors_paused(hass: HomeAssistant) -> None: +async def test_sensors_paused( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -125,10 +125,8 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -147,17 +145,17 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_printer_disconnected(hass: HomeAssistant) -> None: +async def test_sensors_printer_disconnected( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" job = { "job": {}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=None, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=None, job=job) entity_registry = er.async_get(hass) From 47819bde4fb10f54753f6c5445bf94547639d329 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Dec 2023 10:47:51 +0100 Subject: [PATCH 292/927] Migrate sonarr tests to use freezegun (#105410) --- tests/components/sonarr/test_sensor.py | 34 ++++++++++++++------------ 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 9f27e593657..e44081f94bf 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,8 +1,9 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from aiopyarr import ArrException +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -122,6 +123,7 @@ async def test_disabled_by_default_sensors( async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, ) -> None: @@ -129,9 +131,9 @@ async def test_availability( now = dt_util.utcnow() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -140,9 +142,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future = now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -151,9 +153,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -162,9 +164,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -173,9 +175,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" From 32681acc799df70fe28218fac206a597b1f2078b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Dec 2023 12:09:43 +0100 Subject: [PATCH 293/927] Remove Aftership import issue when entry already exists (#105476) --- .../components/aftership/config_flow.py | 23 +++---------------- .../components/aftership/strings.json | 4 ---- .../components/aftership/test_config_flow.py | 10 +++++--- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py index 3da6ac9e3d5..94578091501 100644 --- a/homeassistant/components/aftership/config_flow.py +++ b/homeassistant/components/aftership/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -51,25 +51,6 @@ class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import configuration from yaml.""" - try: - self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) - except AbortFlow as err: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_already_configured", - breaks_in_ha_version="2024.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_already_configured", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "AfterShip", - }, - ) - raise err - async_create_issue( self.hass, HOMEASSISTANT_DOMAIN, @@ -84,6 +65,8 @@ class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): "integration_title": "AfterShip", }, ) + + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) return self.async_create_entry( title=config.get(CONF_NAME, "AfterShip"), data={CONF_API_KEY: config[CONF_API_KEY]}, diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index b49c19976a6..ace8eb6d2d3 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -49,10 +49,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_already_configured": { - "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." - }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py index 2ac5919a555..4668e7a61e4 100644 --- a/tests/components/aftership/test_config_flow.py +++ b/tests/components/aftership/test_config_flow.py @@ -77,7 +77,9 @@ async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> Non } -async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry +) -> None: """Test importing yaml config.""" with patch( @@ -95,11 +97,12 @@ async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: assert result["data"] == { CONF_API_KEY: "yaml-api-key", } - issue_registry = ir.async_get(hass) assert len(issue_registry.issues) == 1 -async def test_import_flow_already_exists(hass: HomeAssistant) -> None: +async def test_import_flow_already_exists( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test importing yaml config where entry already exists.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) entry.add_to_hass(hass) @@ -108,3 +111,4 @@ async def test_import_flow_already_exists(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert len(issue_registry.issues) == 1 From cedac41407f9502e57f768b729f03c6032372d41 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Mon, 11 Dec 2023 13:18:23 +0100 Subject: [PATCH 294/927] Bump python-holidays to 0.38 (#105482) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index f73577bddee..50536bc201d 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.37", "babel==2.13.1"] + "requirements": ["holidays==0.38", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index dd2df87234f..92face1ecdb 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.37"] + "requirements": ["holidays==0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6b75e99ca9e..7f57fdf2a04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1021,7 +1021,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.37 +holidays==0.38 # homeassistant.components.frontend home-assistant-frontend==20231208.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08e7c060269..b993ee71a64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -808,7 +808,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.37 +holidays==0.38 # homeassistant.components.frontend home-assistant-frontend==20231208.2 From c0314cd05c53401ad80d12ca667ea6937645cac2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Dec 2023 14:06:29 +0100 Subject: [PATCH 295/927] Make Workday UI setup nicer (#105407) --- homeassistant/components/workday/config_flow.py | 16 ++++++++-------- homeassistant/components/workday/strings.json | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 348bb0c2fba..9ae31977276 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -155,6 +155,14 @@ DATA_SCHEMA_SETUP = vol.Schema( DATA_SCHEMA_OPT = vol.Schema( { + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( SelectSelectorConfig( options=ALLOWED_DAYS, @@ -166,14 +174,6 @@ DATA_SCHEMA_OPT = vol.Schema( vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) ), - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( - SelectSelectorConfig( - options=ALLOWED_DAYS, - multiple=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key="days", - ) - ), vol.Optional(CONF_ADD_HOLIDAYS, default=[]): SelectSelector( SelectSelectorConfig( options=[], diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 20e7cd26fd6..7e8439af5ea 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -23,13 +23,13 @@ "language": "Language for named holidays" }, "data_description": { - "excludes": "List of workdays to exclude", - "days_offset": "Days offset", - "workdays": "List of workdays", + "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", + "days_offset": "Days offset from current day", + "workdays": "List of working days", "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", - "province": "State, Territory, Province, Region of Country", - "language": "Choose the language you want to configure named holidays after" + "province": "State, territory, province or region of country", + "language": "Language to use when configuring named holiday exclusions" } } }, From 1242456ff1fc8f70ff48503f91d6d54d9a46cfbc Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 11 Dec 2023 17:47:26 +0300 Subject: [PATCH 296/927] Bump openai end switch from dall-e-2 to dall-e-3 (#104998) * Bump openai * Fix tests * Apply suggestions from code review * Undo conftest changes * Raise repasir issue * Explicitly use async mock for chat.completions.create It is not always detected correctly as async because it uses a decorator * removed duplicated message * ruff * Compatibility with old pydantic versions * Compatibility with old pydantic versions * More tests * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Apply suggestions from code review --------- Co-authored-by: Paulus Schoutsen --- .../openai_conversation/__init__.py | 77 +++++--- .../openai_conversation/config_flow.py | 10 +- .../openai_conversation/manifest.json | 2 +- .../openai_conversation/services.yaml | 30 ++- .../openai_conversation/strings.json | 14 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../openai_conversation/conftest.py | 2 +- .../openai_conversation/test_config_flow.py | 23 ++- .../openai_conversation/test_init.py | 178 +++++++++++++++--- 10 files changed, 269 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 054ccbdbe37..b0762979ca2 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -1,12 +1,10 @@ """The OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging from typing import Literal import openai -from openai import error import voluptuous as vol from homeassistant.components import conversation @@ -23,7 +21,13 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) -from homeassistant.helpers import config_validation as cv, intent, selector, template +from homeassistant.helpers import ( + config_validation as cv, + intent, + issue_registry as ir, + selector, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid @@ -52,17 +56,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" - try: - response = await openai.Image.acreate( - api_key=hass.data[DOMAIN][call.data["config_entry"]], - prompt=call.data["prompt"], - n=1, - size=f'{call.data["size"]}x{call.data["size"]}', + client = hass.data[DOMAIN][call.data["config_entry"]] + + if call.data["size"] in ("256", "512", "1024"): + ir.async_create_issue( + hass, + DOMAIN, + "image_size_deprecated_format", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=True, + learn_more_url="https://www.home-assistant.io/integrations/openai_conversation/", + severity=ir.IssueSeverity.WARNING, + translation_key="image_size_deprecated_format", ) - except error.OpenAIError as err: + size = "1024x1024" + else: + size = call.data["size"] + + try: + response = await client.images.generate( + model="dall-e-3", + prompt=call.data["prompt"], + size=size, + quality=call.data["quality"], + style=call.data["style"], + response_format="url", + n=1, + ) + except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating image: {err}") from err - return response["data"][0] + return response.data[0].model_dump(exclude={"b64_json"}) hass.services.async_register( DOMAIN, @@ -76,7 +101,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ), vol.Required("prompt"): cv.string, - vol.Optional("size", default="512"): vol.In(("256", "512", "1024")), + vol.Optional("size", default="1024x1024"): vol.In( + ("1024x1024", "1024x1792", "1792x1024", "256", "512", "1024") + ), + vol.Optional("quality", default="standard"): vol.In(("standard", "hd")), + vol.Optional("style", default="vivid"): vol.In(("vivid", "natural")), } ), supports_response=SupportsResponse.ONLY, @@ -86,21 +115,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" + client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) try: - await hass.async_add_executor_job( - partial( - openai.Model.list, - api_key=entry.data[CONF_API_KEY], - request_timeout=10, - ) - ) - except error.AuthenticationError as err: + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) + except openai.AuthenticationError as err: _LOGGER.error("Invalid API key: %s", err) return False - except error.OpenAIError as err: + except openai.OpenAIError as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data[CONF_API_KEY] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client conversation.async_set_agent(hass, entry, OpenAIAgent(hass, entry)) return True @@ -160,9 +184,10 @@ class OpenAIAgent(conversation.AbstractConversationAgent): _LOGGER.debug("Prompt for %s: %s", model, messages) + client = self.hass.data[DOMAIN][self.entry.entry_id] + try: - result = await openai.ChatCompletion.acreate( - api_key=self.entry.data[CONF_API_KEY], + result = await client.chat.completions.create( model=model, messages=messages, max_tokens=max_tokens, @@ -170,7 +195,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): temperature=temperature, user=conversation_id, ) - except error.OpenAIError as err: + except openai.OpenAIError as err: intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, @@ -181,7 +206,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["message"] + response = result.choices[0].message.model_dump(include={"role", "content"}) messages.append(response) self.history[conversation_id] = messages diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 9c5ef32d796..ef1e498d061 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -1,14 +1,12 @@ """Config flow for OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging import types from types import MappingProxyType from typing import Any import openai -from openai import error import voluptuous as vol from homeassistant import config_entries @@ -59,8 +57,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - openai.api_key = data[CONF_API_KEY] - await hass.async_add_executor_job(partial(openai.Model.list, request_timeout=10)) + client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -81,9 +79,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, user_input) - except error.APIConnectionError: + except openai.APIConnectionError: errors["base"] = "cannot_connect" - except error.AuthenticationError: + except openai.AuthenticationError: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 88d347355e9..5138be96b55 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.27.2"] + "requirements": ["openai==1.3.8"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 81818fb3e71..3db71cae383 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -11,12 +11,30 @@ generate_image: text: multiline: true size: - required: true - example: "512" - default: "512" + required: false + example: "1024x1024" + default: "1024x1024" selector: select: options: - - "256" - - "512" - - "1024" + - "1024x1024" + - "1024x1792" + - "1792x1024" + quality: + required: false + example: "standard" + default: "standard" + selector: + select: + options: + - "standard" + - "hd" + style: + required: false + example: "vivid" + default: "vivid" + selector: + select: + options: + - "vivid" + - "natural" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 542fe06dd56..1a7d5a03c65 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -43,8 +43,22 @@ "size": { "name": "Size", "description": "The size of the image to generate" + }, + "quality": { + "name": "Quality", + "description": "The quality of the image that will be generated" + }, + "style": { + "name": "Style", + "description": "The style of the generated image" } } } + }, + "issues": { + "image_size_deprecated_format": { + "title": "Deprecated size format for image generation service", + "description": "OpenAI is now using Dall-E 3 to generate images when calling `openai_conversation.generate_image`, which supports different sizes. Valid values are now \"1024x1024\", \"1024x1792\", \"1792x1024\". The old values of \"256\", \"512\", \"1024\" are currently interpreted as \"1024x1024\".\nPlease update your scripts or automations with the new parameters." + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 7f57fdf2a04..003d557c73f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1393,7 +1393,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b993ee71a64..9bda1b89845 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1087,7 +1087,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.openerz openerz-api==0.2.0 diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 40f2eb33f08..a83c660e509 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -25,7 +25,7 @@ def mock_config_entry(hass): async def mock_init_component(hass, mock_config_entry): """Initialize integration.""" with patch( - "openai.Model.list", + "openai.resources.models.AsyncModels.list", ): assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 43dfc26ca82..dd218e88c12 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,7 +1,8 @@ """Test the OpenAI Conversation config flow.""" from unittest.mock import patch -from openai.error import APIConnectionError, AuthenticationError, InvalidRequestError +from httpx import Response +from openai import APIConnectionError, AuthenticationError, BadRequestError import pytest from homeassistant import config_entries @@ -32,7 +33,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Model.list", + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", ), patch( "homeassistant.components.openai_conversation.async_setup_entry", return_value=True, @@ -76,9 +77,19 @@ async def test_options( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(""), "cannot_connect"), - (AuthenticationError, "invalid_auth"), - (InvalidRequestError, "unknown"), + (APIConnectionError(request=None), "cannot_connect"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "invalid_auth", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "unknown", + ), ], ) async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None: @@ -88,7 +99,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Model.list", + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 61fe33e5469..d3a06cabeb3 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,7 +1,18 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from openai import error +from httpx import Response +from openai import ( + APIConnectionError, + AuthenticationError, + BadRequestError, + RateLimitError, +) +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.completion_usage import CompletionUsage +from openai.types.image import Image +from openai.types.images_response import ImagesResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -9,6 +20,7 @@ from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, device_registry as dr, intent +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -94,17 +106,30 @@ async def test_default_prompt( suggested_area="Test Area 2", ) with patch( - "openai.ChatCompletion.acreate", - return_value={ - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello, how can I help you?", - } - } - ] - }, + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), ) as mock_create: result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -119,7 +144,11 @@ async def test_error_handling( ) -> None: """Test that the default prompt works.""" with patch( - "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message=None + ), ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -140,8 +169,11 @@ async def test_template_error( }, ) with patch( - "openai.Model.list", - ), patch("openai.ChatCompletion.acreate"): + "openai.resources.models.AsyncModels.list", + ), patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -169,15 +201,67 @@ async def test_conversation_agent( [ ( {"prompt": "Picture of a dog"}, - {"prompt": "Picture of a dog", "size": "512x512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, ), ( {"prompt": "Picture of a dog", "size": "256"}, - {"prompt": "Picture of a dog", "size": "256x256"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + {"prompt": "Picture of a dog", "size": "512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ( {"prompt": "Picture of a dog", "size": "1024"}, - {"prompt": "Picture of a dog", "size": "1024x1024"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ], ) @@ -190,11 +274,22 @@ async def test_generate_image_service( ) -> None: """Test generate image service.""" service_data["config_entry"] = mock_config_entry.entry_id - expected_args["api_key"] = mock_config_entry.data["api_key"] + expected_args["model"] = "dall-e-3" + expected_args["response_format"] = "url" expected_args["n"] = 1 with patch( - "openai.Image.acreate", return_value={"data": [{"url": "A"}]} + "openai.resources.images.AsyncImages.generate", + return_value=ImagesResponse( + created=1700000000, + data=[ + Image( + b64_json=None, + revised_prompt="A clear and detailed picture of an ordinary canine", + url="A", + ) + ], + ), ) as mock_create: response = await hass.services.async_call( "openai_conversation", @@ -204,7 +299,10 @@ async def test_generate_image_service( return_response=True, ) - assert response == {"url": "A"} + assert response == { + "url": "A", + "revised_prompt": "A clear and detailed picture of an ordinary canine", + } assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][2] == expected_args @@ -216,7 +314,10 @@ async def test_generate_image_service_error( ) -> None: """Test generate image service handles errors.""" with patch( - "openai.Image.acreate", side_effect=error.ServiceUnavailableError("Reason") + "openai.resources.images.AsyncImages.generate", + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message="Reason" + ), ), pytest.raises(HomeAssistantError, match="Error generating image: Reason"): await hass.services.async_call( "openai_conversation", @@ -228,3 +329,34 @@ async def test_generate_image_service_error( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIConnectionError(request=None), "Connection error"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "Invalid API key", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "openai_conversation integration not ready yet: None", + ), + ], +) +async def test_init_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog, side_effect, error +) -> None: + """Test initialization errors.""" + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=side_effect, + ): + assert await async_setup_component(hass, "openai_conversation", {}) + await hass.async_block_till_done() + assert error in caplog.text From 44e54e11d8e390e68849e758b2e599ad7a860bba Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 11 Dec 2023 15:58:51 +0100 Subject: [PATCH 297/927] Follow Alpine 3.18 raspberrypi package updates (#105486) Alpine 3.18 renamed the packages raspberrypi and raspberrypi-libs to raspberrypi-userland and raspberrypi-userland-libs respectively. Follow that rename. With this moderniziation raspistill and friends now gets deployed to /usr/bin, which makes any symlinks obsolete. Note that there is and was never a 64-bit variant of raspistill. So these symlinks were essentially useless all along. This effectively doesn't change anything for users: Alpine automatically installed the renamed package already and Home Assistant Core picked up the raspistill binary from /usr/bin already. --- machine/raspberrypi | 11 ++--------- machine/raspberrypi2 | 11 ++--------- machine/raspberrypi3 | 11 ++--------- machine/raspberrypi3-64 | 11 ++--------- machine/raspberrypi4 | 11 ++--------- machine/raspberrypi4-64 | 11 ++--------- machine/yellow | 11 ++--------- 7 files changed, 14 insertions(+), 63 deletions(-) diff --git a/machine/raspberrypi b/machine/raspberrypi index 3cce504661e..2ed3b3c8e44 100644 --- a/machine/raspberrypi +++ b/machine/raspberrypi @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi camera binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi2 b/machine/raspberrypi2 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi2 +++ b/machine/raspberrypi2 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/yellow b/machine/yellow index c49db40b408..2ed3b3c8e44 100644 --- a/machine/yellow +++ b/machine/yellow @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs From 3963f59121423c1e68f818cd528a319d19c04a0f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 11 Dec 2023 16:12:32 +0100 Subject: [PATCH 298/927] Reduce modbus validator for "swap" (remove special handling) (#105021) --- homeassistant/components/modbus/validators.py | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 7dc5a91a2fa..5e2129bd90a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -120,34 +120,30 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm swap_type = config.get(CONF_SWAP) + swap_dict = { + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + } + swap_type_validator = swap_dict[swap_type] if swap_type else OPTIONAL for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), ( slave_count, validator.slave_count, - f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}", + f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}:", ), + (swap_type, swap_type_validator, f"{CONF_SWAP}:{swap_type}"), ): if entry[0] is None: if entry[1] == DEMANDED: - error = f"{name}: `{entry[2]}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + error = f"{name}: `{entry[2]}` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) elif entry[1] == ILLEGAL: - error = ( - f"{name}: `{entry[2]}:` illegal with `{CONF_DATA_TYPE}: {data_type}`" - ) + error = f"{name}: `{entry[2]}` illegal with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) - if swap_type: - swap_type_validator = { - CONF_SWAP_BYTE: validator.swap_byte, - CONF_SWAP_WORD: validator.swap_word, - CONF_SWAP_WORD_BYTE: validator.swap_word, - }[swap_type] - if swap_type_validator == ILLEGAL: - error = f"{name}: `{CONF_SWAP}:{swap_type}` illegal with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: size = struct.calcsize(structure) From 4c0fda9ca032732d096782de1f2788b73c773cf4 Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Mon, 11 Dec 2023 10:27:02 -0500 Subject: [PATCH 299/927] Fix Lyric LCC thermostats auto mode (#104853) --- homeassistant/components/lyric/climate.py | 109 +++++++++++++--------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index f01e4c4fe55..e2504232c68 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import enum import logging from time import localtime, strftime, time from typing import Any @@ -151,6 +152,13 @@ async def async_setup_entry( ) +class LyricThermostatType(enum.Enum): + """Lyric thermostats are classified as TCC or LCC devices.""" + + TCC = enum.auto() + LCC = enum.auto() + + class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" @@ -201,8 +209,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): # Setup supported features if device.changeableValues.thermostatSetpointStatus: self._attr_supported_features = SUPPORT_FLAGS_LCC + self._attr_thermostat_type = LyricThermostatType.LCC else: self._attr_supported_features = SUPPORT_FLAGS_TCC + self._attr_thermostat_type = LyricThermostatType.TCC # Setup supported fan modes if device_fan_modes := device.settings.attributes.get("fan", {}).get( @@ -365,56 +375,69 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): """Set hvac mode.""" _LOGGER.debug("HVAC mode: %s", hvac_mode) try: - if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: - # If the system is off, turn it to Heat first then to Auto, - # otherwise it turns to. - # Auto briefly and then reverts to Off (perhaps related to - # heatCoolMode). This is the behavior that happens with the - # native app as well, so likely a bug in the api itself - if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_COOL], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=False, - ) - # Sleep 3 seconds before proceeding - await asyncio.sleep(3) - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=True, - ) - else: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[self.device.changeableValues.mode], - ) - await self._update_thermostat( - self.location, self.device, autoChangeoverActive=True - ) - else: + match self._attr_thermostat_type: + case LyricThermostatType.TCC: + await self._async_set_hvac_mode_tcc(hvac_mode) + case LyricThermostatType.LCC: + await self._async_set_hvac_mode_lcc(hvac_mode) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: + if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: + # If the system is off, turn it to Heat first then to Auto, + # otherwise it turns to. + # Auto briefly and then reverts to Off (perhaps related to + # heatCoolMode). This is the behavior that happens with the + # native app as well, so likely a bug in the api itself + if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: _LOGGER.debug( - "HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode] + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_COOL], ) await self._update_thermostat( self.location, self.device, - mode=LYRIC_HVAC_MODES[hvac_mode], + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], autoChangeoverActive=False, ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + # Sleep 3 seconds before proceeding + await asyncio.sleep(3) + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + ) + await self._update_thermostat( + self.location, + self.device, + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + autoChangeoverActive=True, + ) + else: + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[self.device.changeableValues.mode], + ) + await self._update_thermostat( + self.location, self.device, autoChangeoverActive=True + ) + else: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + autoChangeoverActive=False, + ) + + async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode.""" From 94fd7d0353a8ddca0809d631c394af42fd6d7887 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Dec 2023 16:48:12 +0100 Subject: [PATCH 300/927] Improve test of config entry store (#105487) * Improve test of config entry store * Tweak test --- tests/snapshots/test_config_entries.ambr | 18 ++++++++++ tests/test_config_entries.py | 46 +++++++++++++++++++----- 2 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 tests/snapshots/test_config_entries.ambr diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr new file mode 100644 index 00000000000..beaa60cf762 --- /dev/null +++ b/tests/snapshots/test_config_entries.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_as_dict + dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'test', + 'entry_id': 'mock-entry', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }) +# --- diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index f63972c79e8..40e3b3b4c3c 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader from homeassistant.components import dhcp @@ -667,14 +668,43 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: for orig, loaded in zip( hass.config_entries.async_entries(), manager.async_entries() ): - assert orig.version == loaded.version - assert orig.domain == loaded.domain - assert orig.title == loaded.title - assert orig.data == loaded.data - assert orig.source == loaded.source - assert orig.unique_id == loaded.unique_id - assert orig.pref_disable_new_entities == loaded.pref_disable_new_entities - assert orig.pref_disable_polling == loaded.pref_disable_polling + assert orig.as_dict() == loaded.as_dict() + + +async def test_as_dict(snapshot: SnapshotAssertion) -> None: + """Test ConfigEntry.as_dict.""" + + # Ensure as_dict is not overridden + assert MockConfigEntry.as_dict is config_entries.ConfigEntry.as_dict + + excluded_from_dict = { + "supports_unload", + "supports_remove_device", + "state", + "_setup_lock", + "update_listeners", + "reason", + "_async_cancel_retry_setup", + "_on_unload", + "reload_lock", + "_reauth_lock", + "_tasks", + "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", + } + + entry = MockConfigEntry(entry_id="mock-entry") + + # Make sure the expected keys are present + dict_repr = entry.as_dict() + for key in config_entries.ConfigEntry.__slots__: + assert key in dict_repr or key in excluded_from_dict + assert not (key in dict_repr and key in excluded_from_dict) + + # Make sure the dict representation is as expected + assert dict_repr == snapshot async def test_forward_entry_sets_up_component(hass: HomeAssistant) -> None: From b71f488d3e544fbc51567dec43cba108a02bfd7b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:04:07 +0100 Subject: [PATCH 301/927] Update pylint to 3.0.3 (#105491) --- homeassistant/components/improv_ble/config_flow.py | 2 +- homeassistant/components/mqtt/__init__.py | 1 - homeassistant/components/zha/__init__.py | 2 +- requirements_test.txt | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index bfc86ac0162..762f37ef5d4 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -405,7 +405,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): raise AbortFlow("characteristic_missing") from err except improv_ble_errors.CommandFailed: raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Unexpected exception") raise AbortFlow("unknown") from err diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 16f584db011..593d5bbd202 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -247,7 +247,6 @@ async def async_check_config_schema( schema(config) except vol.Invalid as exc: integration = await async_get_integration(hass, DOMAIN) - # pylint: disable-next=protected-access message = conf_util.format_schema_error( hass, exc, domain, config, integration.documentation ) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 2046070d6a5..340e0db40a6 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -182,7 +182,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) from exc except TransientConnectionError as exc: raise ConfigEntryNotReady from exc - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: _LOGGER.debug( "Couldn't start coordinator (attempt %s of %s)", attempt + 1, diff --git a/requirements_test.txt b/requirements_test.txt index f8918dc73f4..9b30c0e40a1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ mock-open==1.4.0 mypy==1.7.1 pre-commit==3.5.0 pydantic==1.10.12 -pylint==3.0.2 +pylint==3.0.3 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 From 80607f77509f8e38f368c9c4ccfaba7dda028290 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 11 Dec 2023 10:18:46 -0600 Subject: [PATCH 302/927] Disconnect before reconnecting to satellite (#105500) Disconnect before reconnecting --- homeassistant/components/wyoming/satellite.py | 26 ++++++++++++++++--- tests/components/wyoming/test_satellite.py | 23 ++++++++++++---- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 94f61c17047..2c93b762015 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -71,11 +71,11 @@ class WyomingSatellite: while self.is_running: try: # Check if satellite has been disabled - if not self.device.is_enabled: + while not self.device.is_enabled: await self.on_disabled() if not self.is_running: # Satellite was stopped while waiting to be enabled - break + return # Connect and run pipeline loop await self._run_once() @@ -87,7 +87,7 @@ class WyomingSatellite: # Ensure sensor is off self.device.set_is_active(False) - await self.on_stopped() + await self.on_stopped() def stop(self) -> None: """Signal satellite task to stop running.""" @@ -130,6 +130,7 @@ class WyomingSatellite: self._audio_queue.put_nowait(None) self._enabled_changed_event.set() + self._enabled_changed_event.clear() def _pipeline_changed(self) -> None: """Run when device pipeline changes.""" @@ -255,9 +256,17 @@ class WyomingSatellite: chunk = AudioChunk.from_event(client_event) chunk = self._chunk_converter.convert(chunk) self._audio_queue.put_nowait(chunk.audio) + elif AudioStop.is_type(client_event.type): + # Stop pipeline + _LOGGER.debug("Client requested pipeline to stop") + self._audio_queue.put_nowait(b"") + break else: _LOGGER.debug("Unexpected event from satellite: %s", client_event) + # Ensure task finishes + await _pipeline_task + _LOGGER.debug("Pipeline finished") def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: @@ -348,12 +357,23 @@ class WyomingSatellite: async def _connect(self) -> None: """Connect to satellite over TCP.""" + await self._disconnect() + _LOGGER.debug( "Connecting to satellite at %s:%s", self.service.host, self.service.port ) self._client = AsyncTcpClient(self.service.host, self.service.port) await self._client.connect() + async def _disconnect(self) -> None: + """Disconnect if satellite is currently connected.""" + if self._client is None: + return + + _LOGGER.debug("Disconnecting from satellite") + await self._client.disconnect() + self._client = None + async def _stream_tts(self, media_id: str) -> None: """Stream TTS WAV audio to satellite in chunks.""" assert self._client is not None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 50252007aa5..83e4d98d971 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -322,11 +322,12 @@ async def test_satellite_disabled(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService ): satellite = original_make_satellite(hass, config_entry, service) - satellite.device.is_enabled = False + satellite.device.set_is_enabled(False) return satellite async def on_disabled(self): + self.device.set_is_enabled(True) on_disabled_event.set() with patch( @@ -368,11 +369,19 @@ async def test_satellite_restart(hass: HomeAssistant) -> None: async def test_satellite_reconnect(hass: HomeAssistant) -> None: """Test satellite reconnect call after connection refused.""" - on_reconnect_event = asyncio.Event() + num_reconnects = 0 + reconnect_event = asyncio.Event() + stopped_event = asyncio.Event() async def on_reconnect(self): - self.stop() - on_reconnect_event.set() + nonlocal num_reconnects + num_reconnects += 1 + if num_reconnects >= 2: + reconnect_event.set() + self.stop() + + async def on_stopped(self): + stopped_event.set() with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -383,10 +392,14 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", on_reconnect, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, ): await setup_config_entry(hass) async with asyncio.timeout(1): - await on_reconnect_event.wait() + await reconnect_event.wait() + await stopped_event.wait() async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: From 6c2b3ef950c7f738274f0a2f172dca3894b52db7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:30:24 +0100 Subject: [PATCH 303/927] Update typing-extensions to 4.9.0 (#105490) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f1373b61d9..4ee6c9ba3ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.23 -typing-extensions>=4.8.0,<5.0 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/pyproject.toml b/pyproject.toml index 9e9e8de4916..7b1b025ee24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.8.0,<5.0", + "typing-extensions>=4.9.0,<5.0", "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index 1b5b8d63c54..250a0948714 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.8.0,<5.0 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From d1ea04152a1f6b569212b84c7f85ded1376ce2f6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 11 Dec 2023 17:37:15 +0100 Subject: [PATCH 304/927] Bump reolink_aio to 0.8.3 (#105489) --- 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 e03fa28b7ce..7dc81e83b53 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.8.2"] + "requirements": ["reolink-aio==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 003d557c73f..7b76caf4c28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2354,7 +2354,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.2 +reolink-aio==0.8.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bda1b89845..2ca0e164808 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1766,7 +1766,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.2 +reolink-aio==0.8.3 # homeassistant.components.rflink rflink==0.0.65 From 837ce99e3095c4490930361e80988c5ccca71041 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 11 Dec 2023 17:39:48 +0100 Subject: [PATCH 305/927] Add Raspberry Pi 5 specific container image (#105488) --- .github/workflows/builder.yml | 5 +++-- machine/raspberrypi5-64 | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 machine/raspberrypi5-64 diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7c3a42aaaa1..a646510582a 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.09.0 + uses: home-assistant/builder@2023.12.0 with: args: | $BUILD_ARGS \ @@ -247,6 +247,7 @@ jobs: - raspberrypi3-64 - raspberrypi4 - raspberrypi4-64 + - raspberrypi5-64 - tinker - yellow - green @@ -273,7 +274,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.09.0 + uses: home-assistant/builder@2023.12.0 with: args: | $BUILD_ARGS \ diff --git a/machine/raspberrypi5-64 b/machine/raspberrypi5-64 new file mode 100644 index 00000000000..2ed3b3c8e44 --- /dev/null +++ b/machine/raspberrypi5-64 @@ -0,0 +1,8 @@ +ARG \ + BUILD_FROM + +FROM $BUILD_FROM + +RUN apk --no-cache add \ + raspberrypi-userland \ + raspberrypi-userland-libs From bb0d082b25913bb154c2da0314994b4b9fe4538b Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 11 Dec 2023 19:17:06 +0100 Subject: [PATCH 306/927] Correctly report unavailable battery for value 255 of percentage (#104566) * Ignore unavailable battery level for zha * Adjust unavailable test --- homeassistant/components/zha/sensor.py | 2 +- tests/components/zha/test_sensor.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 4fe96109c46..4ec4c11ef53 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -231,7 +231,7 @@ class Battery(Sensor): 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: + if not isinstance(value, numbers.Number) or value == -1 or value == 255: return None value = round(value / 2) return value diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 7c11077c55d..59b8bb1293e 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -260,11 +260,12 @@ async def async_test_powerconfiguration2(hass, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: -1}) assert_state(hass, entity_id, STATE_UNKNOWN, "%") - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.9 - assert hass.states.get(entity_id).attributes["battery_quantity"] == 3 - assert hass.states.get(entity_id).attributes["battery_size"] == "AAA" - await send_attributes_report(hass, cluster, {32: 20}) - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 + + await send_attributes_report(hass, cluster, {33: 255}) + assert_state(hass, entity_id, STATE_UNKNOWN, "%") + + await send_attributes_report(hass, cluster, {33: 98}) + assert_state(hass, entity_id, "49", "%") async def async_test_device_temperature(hass, cluster, entity_id): From dd338799d4b8a05983ddf6819132ae0b875bc041 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Dec 2023 20:00:55 +0100 Subject: [PATCH 307/927] Make it possible to inherit EntityDescription in frozen and mutable dataclasses (#105211) --- .../bluetooth/passive_update_processor.py | 4 +- homeassistant/components/iotawatt/sensor.py | 20 +-- .../components/litterrobot/vacuum.py | 2 +- homeassistant/components/zwave_js/sensor.py | 114 ++++++++-------- homeassistant/helpers/entity.py | 19 ++- homeassistant/util/frozen_dataclass_compat.py | 127 ++++++++++++++++++ tests/helpers/snapshots/test_entity.ambr | 45 +++++++ tests/helpers/test_entity.py | 51 ++++++- 8 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 homeassistant/util/frozen_dataclass_compat.py create mode 100644 tests/helpers/snapshots/test_entity.ambr diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8da0d2c462b..eeccf081b55 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -94,7 +94,7 @@ def deserialize_entity_description( ) -> EntityDescription: """Deserialize an entity description.""" result: dict[str, Any] = {} - for field in cached_fields(descriptions_class): # type: ignore[arg-type] + for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual # type instead of a str so we could avoid writing this @@ -114,7 +114,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An as_dict = dataclasses.asdict(description) return { field.name: as_dict[field.name] - for field in cached_fields(type(description)) # type: ignore[arg-type] + for field in cached_fields(type(description)) if field.default != as_dict.get(field.name) } diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 27ecc1574e3..7dd26c46201 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -45,14 +45,14 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "Amps": IotaWattSensorEntityDescription( - "Amps", + key="Amps", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( - "Hz", + key="Hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, @@ -60,7 +60,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( - "PF", + key="PF", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER_FACTOR, @@ -68,40 +68,40 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( - "Watts", + key="Watts", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), "WattHours": IotaWattSensorEntityDescription( - "WattHours", + key="WattHours", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), "VA": IotaWattSensorEntityDescription( - "VA", + key="VA", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.APPARENT_POWER, entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( - "VAR", + key="VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( - "VARh", + key="VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( - "Volts", + key="Volts", native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, @@ -125,7 +125,7 @@ async def async_setup_entry( created.add(key) data = coordinator.data["sensors"][key] description = ENTITY_DESCRIPTION_KEY_MAP.get( - data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + data.getUnit(), IotaWattSensorEntityDescription(key="base_sensor") ) return IotaWattSensor( diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 4b1a8effb98..a86f1e4be00 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -47,7 +47,7 @@ LITTER_BOX_STATUS_STATE_MAP = { } LITTER_BOX_ENTITY = StateVacuumEntityDescription( - "litter_box", translation_key="litter_box" + key="litter_box", translation_key="litter_box" ) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 8d42bcfb366..56ed3f010b8 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -111,20 +111,20 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ tuple[str, str], SensorEntityDescription ] = { (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_BATTERY, + key=ENTITY_DESC_KEY_BATTERY, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( - ENTITY_DESC_KEY_CURRENT, + key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), (ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.VOLT): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -133,7 +133,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.MILLIVOLT, ): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -142,67 +142,67 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, UnitOfEnergy.KILO_WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), (ENTITY_DESC_KEY_POWER, UnitOfPower.WATT): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), (ENTITY_DESC_KEY_POWER_FACTOR, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CO, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_CO2, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO2, + key=ENTITY_DESC_KEY_CO2, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_HUMIDITY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_ILLUMINANCE, LIGHT_LUX): SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.KPA): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.KPA, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.PSI): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.PSI, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.INHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.INHG, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.MMHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.MMHG, @@ -211,7 +211,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -219,7 +219,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), (ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.CELSIUS): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -228,7 +228,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, @@ -237,7 +237,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.CELSIUS, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), @@ -245,7 +245,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), @@ -253,13 +253,13 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.SECONDS, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, name="Energy production time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, ), (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, ), @@ -267,7 +267,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, name="Energy production today", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -277,7 +277,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, name="Energy production total", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -287,7 +287,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, UnitOfPower.WATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, name="Energy production power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -298,41 +298,41 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ # These descriptions are without device class. ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_CO: SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + key=ENTITY_DESC_KEY_ENERGY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_MEASUREMENT, + key=ENTITY_DESC_KEY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( - ENTITY_DESC_KEY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( - ENTITY_DESC_KEY_UV_INDEX, + key=ENTITY_DESC_KEY_UV_INDEX, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), @@ -342,80 +342,80 @@ ENTITY_DESCRIPTION_KEY_MAP = { # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ SensorEntityDescription( - "messagesTX", + key="messagesTX", name="Successful messages (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesRX", + key="messagesRX", name="Successful messages (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedTX", + key="messagesDroppedTX", name="Messages dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedRX", + key="messagesDroppedRX", name="Messages dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "NAK", + key="NAK", name="Messages not accepted", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutCallback", + key="timeoutCallback", name="Timed out callbacks", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "backgroundRSSI.channel0.average", + key="backgroundRSSI.channel0.average", name="Average background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel0.current", + key="backgroundRSSI.channel0.current", name="Current background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel1.average", + key="backgroundRSSI.channel1.average", name="Average background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel1.current", + key="backgroundRSSI.channel1.current", name="Current background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel2.average", + key="backgroundRSSI.channel2.average", name="Average background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel2.current", + key="backgroundRSSI.channel2.current", name="Current background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -426,39 +426,39 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ SensorEntityDescription( - "commandsRX", + key="commandsRX", name="Successful commands (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsTX", + key="commandsTX", name="Successful commands (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedRX", + key="commandsDroppedRX", name="Commands dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedTX", + key="commandsDroppedTX", name="Commands dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "rtt", + key="rtt", name="Round Trip Time", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "rssi", + key="rssi", name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -478,7 +478,7 @@ def get_entity_description( ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, SensorEntityDescription( - "base_sensor", native_unit_of_measurement=data.unit_of_measurement + key="base_sensor", native_unit_of_measurement=data.unit_of_measurement ), ), ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7877ca0e613..6446a4fe6d6 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from dataclasses import dataclass +import dataclasses from datetime import timedelta from enum import Enum, auto import functools as ft @@ -23,6 +23,7 @@ from typing import ( final, ) +from typing_extensions import dataclass_transform import voluptuous as vol from homeassistant.backports.functools import cached_property @@ -51,6 +52,7 @@ from homeassistant.exceptions import ( ) from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify +from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -218,8 +220,17 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass(slots=True) -class EntityDescription: +@dataclass_transform( + field_specifiers=(dataclasses.field, dataclasses.Field), + kw_only_default=True, # Set to allow setting kw_only in child classes +) +class _EntityDescriptionBase: + """Add PEP 681 decorator (dataclass transform).""" + + +class EntityDescription( + _EntityDescriptionBase, metaclass=FrozenOrThawed, frozen_or_thawed=True +): """A class that describes Home Assistant entities.""" # This is the key identifier for this entity @@ -1245,7 +1256,7 @@ class Entity(ABC): ) -@dataclass(slots=True) +@dataclasses.dataclass(slots=True) class ToggleEntityDescription(EntityDescription): """A class that describes toggle entities.""" diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py new file mode 100644 index 00000000000..96053844ab5 --- /dev/null +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -0,0 +1,127 @@ +"""Utility to create classes from which frozen or mutable dataclasses can be derived. + +This module enabled a non-breaking transition from mutable to frozen dataclasses +derived from EntityDescription and sub classes thereof. +""" +from __future__ import annotations + +import dataclasses +import sys +from typing import Any + + +def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: + """Return a list of dataclass fields. + + Extracted from dataclasses._process_class. + """ + # pylint: disable=protected-access + cls_annotations = cls.__dict__.get("__annotations__", {}) + + cls_fields: list[dataclasses.Field[Any]] = [] + + _dataclasses = sys.modules[dataclasses.__name__] + for name, _type in cls_annotations.items(): + # See if this is a marker to change the value of kw_only. + if dataclasses._is_kw_only(type, _dataclasses) or ( # type: ignore[attr-defined] + isinstance(_type, str) + and dataclasses._is_type( # type: ignore[attr-defined] + _type, + cls, + _dataclasses, + dataclasses.KW_ONLY, + dataclasses._is_kw_only, # type: ignore[attr-defined] + ) + ): + kw_only = True + else: + # Otherwise it's a field of some type. + cls_fields.append(dataclasses._get_field(cls, name, _type, kw_only)) # type: ignore[attr-defined] + + return [(field.name, field.type, field) for field in cls_fields] + + +class FrozenOrThawed(type): + """Metaclass which which makes classes which behave like a dataclass. + + This allows child classes to be either mutable or frozen dataclasses. + """ + + def _make_dataclass(cls, name: str, bases: tuple[type, ...], kw_only: bool) -> None: + class_fields = _class_fields(cls, kw_only) + dataclass_bases = [] + for base in bases: + dataclass_bases.append(getattr(base, "_dataclass", base)) + cls._dataclass = dataclasses.make_dataclass( + f"{name}_dataclass", class_fields, bases=tuple(dataclass_bases), frozen=True + ) + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + frozen_or_thawed: bool = False, + **kwargs: Any, + ) -> Any: + """Pop frozen_or_thawed and store it in the namespace.""" + namespace["_FrozenOrThawed__frozen_or_thawed"] = frozen_or_thawed + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Optionally create a dataclass and store it in cls._dataclass. + + A dataclass will be created if frozen_or_thawed is set, if not we assume the + class will be a real dataclass, i.e. it's decorated with @dataclass. + """ + if not namespace["_FrozenOrThawed__frozen_or_thawed"]: + parent = cls.__mro__[1] + # This class is a real dataclass, optionally inject the parent's annotations + if dataclasses.is_dataclass(parent) or not hasattr(parent, "_dataclass"): + # Rely on dataclass inheritance + return + # Parent is not a dataclass, inject its annotations + cls.__annotations__ = ( + parent._dataclass.__annotations__ | cls.__annotations__ + ) + return + + # First try without setting the kw_only flag, and if that fails, try setting it + try: + cls._make_dataclass(name, bases, False) + except TypeError: + cls._make_dataclass(name, bases, True) + + def __delattr__(self: object, name: str) -> None: + """Delete an attribute. + + If self is a real dataclass, this is called if the dataclass is not frozen. + If self is not a real dataclass, forward to cls._dataclass.__delattr. + """ + if dataclasses.is_dataclass(self): + return object.__delattr__(self, name) + return self._dataclass.__delattr__(self, name) # type: ignore[attr-defined, no-any-return] + + def __setattr__(self: object, name: str, value: Any) -> None: + """Set an attribute. + + If self is a real dataclass, this is called if the dataclass is not frozen. + If self is not a real dataclass, forward to cls._dataclass.__setattr__. + """ + if dataclasses.is_dataclass(self): + return object.__setattr__(self, name, value) + return self._dataclass.__setattr__(self, name, value) # type: ignore[attr-defined, no-any-return] + + # Set generated dunder methods from the dataclass + # MyPy doesn't understand what's happening, so we ignore it + cls.__delattr__ = __delattr__ # type: ignore[assignment, method-assign] + cls.__eq__ = cls._dataclass.__eq__ # type: ignore[method-assign] + cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] + cls.__repr__ = cls._dataclass.__repr__ # type: ignore[method-assign] + cls.__setattr__ = __setattr__ # type: ignore[assignment, method-assign] diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr new file mode 100644 index 00000000000..3b04286b62f --- /dev/null +++ b/tests/helpers/snapshots/test_entity.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_entity_description_as_dataclass + EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None) +# --- +# name: test_entity_description_as_dataclass.1 + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.1 + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.2 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.3 + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4076afcfad0..66ba9f947c9 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import MagicMock, PropertyMock, patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.const import ( @@ -966,7 +967,7 @@ async def test_entity_description_fallback() -> None: ent_with_description = entity.Entity() ent_with_description.entity_description = entity.EntityDescription(key="test") - for field in dataclasses.fields(entity.EntityDescription): + for field in dataclasses.fields(entity.EntityDescription._dataclass): if field.name == "key": continue @@ -1657,3 +1658,51 @@ async def test_change_entity_id( assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + + +def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): + """Test EntityDescription behaves like a dataclass.""" + + obj = entity.EntityDescription("blah", device_class="test") + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + assert obj == snapshot + assert obj == entity.EntityDescription("blah", device_class="test") + assert repr(obj) == snapshot + + +def test_extending_entity_description(snapshot: SnapshotAssertion): + """Test extending entity descriptions.""" + + @dataclasses.dataclass(frozen=True) + class FrozenEntityDescription(entity.EntityDescription): + extra: str = None + + obj = FrozenEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == FrozenEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + @dataclasses.dataclass + class ThawedEntityDescription(entity.EntityDescription): + extra: str = None + + obj = ThawedEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == ThawedEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + obj.name = "mutate" + assert obj.name == "mutate" + delattr(obj, "key") + assert not hasattr(obj, "key") From 0dc61b34930f6fdafa3532050987431a3d317b69 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 11 Dec 2023 20:30:19 +0100 Subject: [PATCH 308/927] Add typing in Melcloud config flow (#105510) * Add typing in config flow * Patching functions with typing --- homeassistant/components/melcloud/config_flow.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index b19e268a4c3..9293c9bb3d5 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -63,7 +63,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None = None - async def _create_entry(self, username: str, token: str): + async def _create_entry(self, username: str, token: str) -> FlowResult: """Register new entry.""" await self.async_set_unique_id(username) try: @@ -81,7 +81,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): *, password: str | None = None, token: str | None = None, - ): + ) -> FlowResult: """Create client.""" try: async with asyncio.timeout(10): @@ -113,7 +113,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._create_entry(username, acquired_token) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """User initiated config flow.""" if user_input is None: return self.async_show_form( @@ -125,7 +127,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] return await self._create_client(username, password=user_input[CONF_PASSWORD]) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" result = await self._create_client( user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] From e890671192bc69b789f07399e16edf2a4092110d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Dec 2023 10:42:00 -1000 Subject: [PATCH 309/927] Relocate Bluetooth manager to habluetooth library (#105110) * Relocate Bluetooth manager to habluetooth library * Relocate Bluetooth manager to habluetooth library * Relocate Bluetooth manager to habluetooth library * fixes * fix patching time * fix more tests * fix more tests * split * Bump habluetooth to 0.7.0 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v0.6.1...v0.7.0 This is the big change that will move the manager so the HA PR that will follow this will be a bit larger than the rest of them since the manager is connected to everything * fix types * fix types * fix types * fix patch targets * fix flakey logbook tests (will need another PR) * mock shutdown * bump again * value can be a float now * Revert "value can be a float now" This reverts commit b7e7127143bd2947345c7590fc2727aa47e28d88. * float --- .../components/bluetooth/__init__.py | 13 +- homeassistant/components/bluetooth/api.py | 9 +- .../components/bluetooth/base_scanner.py | 14 +- homeassistant/components/bluetooth/manager.py | 653 +----------------- homeassistant/components/bluetooth/models.py | 9 +- homeassistant/components/bluetooth/usage.py | 51 -- .../components/bluetooth/wrappers.py | 391 ----------- tests/components/bluetooth/__init__.py | 8 +- .../bluetooth/test_advertisement_tracker.py | 47 +- tests/components/bluetooth/test_init.py | 2 +- tests/components/bluetooth/test_manager.py | 25 +- tests/components/bluetooth/test_models.py | 5 +- .../test_passive_update_coordinator.py | 18 +- .../test_passive_update_processor.py | 11 +- tests/components/bluetooth/test_usage.py | 55 +- tests/components/bluetooth/test_wrappers.py | 36 +- tests/components/bthome/test_binary_sensor.py | 17 +- tests/components/bthome/test_sensor.py | 17 +- tests/components/govee_ble/test_sensor.py | 12 +- tests/components/oralb/test_sensor.py | 12 +- .../components/private_ble_device/__init__.py | 7 +- .../private_ble_device/test_sensor.py | 4 +- .../components/qingping/test_binary_sensor.py | 7 +- tests/components/qingping/test_sensor.py | 7 +- tests/components/sensorpush/test_sensor.py | 7 +- .../xiaomi_ble/test_binary_sensor.py | 17 +- tests/components/xiaomi_ble/test_sensor.py | 17 +- 27 files changed, 145 insertions(+), 1326 deletions(-) delete mode 100644 homeassistant/components/bluetooth/usage.py delete mode 100644 homeassistant/components/bluetooth/wrappers.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 329b597d515..4a53347e826 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,11 +21,15 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( + BaseHaScanner, + BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, HaScanner, ScannerStartError, + set_manager, ) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak @@ -65,11 +69,7 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import ( - BaseHaScanner, - BluetoothScannerDevice, - HomeAssistantRemoteScanner, -) +from .base_scanner import HomeAssistantRemoteScanner from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -81,7 +81,7 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage @@ -146,6 +146,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) + set_manager(manager) await manager.async_setup() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index afdd26a2001..4acb8d91c84 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,17 +9,20 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast -from habluetooth import BluetoothScanningMode +from habluetooth import ( + BaseHaScanner, + BluetoothScannerDevice, + BluetoothScanningMode, + HaBleakScannerWrapper, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback -from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: from bleak.backends.device import BLEDevice diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8267a73fd71..b8e1e909ad2 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -2,13 +2,10 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from typing import Any -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from bluetooth_adapters import DiscoveredDeviceAdvertisementData -from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector +from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -22,15 +19,6 @@ from homeassistant.core import ( from . import models -@dataclass(slots=True) -class BluetoothScannerDevice: - """Data for a bluetooth device from a given scanner.""" - - scanner: BaseHaScanner - ble_device: BLEDevice - advertisement: AdvertisementData - - class HomeAssistantRemoteScanner(BaseHaRemoteScanner): """Home Assistant remote BLE scanner. diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 777d0ebe317..848460455ca 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,22 +1,13 @@ """The bluetooth integration.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Iterable import itertools import logging -from typing import TYPE_CHECKING, Any, Final -from bleak.backends.scanner import AdvertisementDataCallback -from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD, BleakSlotManager -from bluetooth_adapters import ( - ADAPTER_ADDRESS, - ADAPTER_PASSIVE_SCAN, - AdapterDetails, - BluetoothAdapters, -) -from bluetooth_data_tools import monotonic_time_coarse -from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker +from bleak_retry_connector import BleakSlotManager +from bluetooth_adapters import BluetoothAdapters +from habluetooth import BluetoothManager from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -28,11 +19,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import discovery_flow -from .base_scanner import BaseHaScanner, BluetoothScannerDevice -from .const import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - UNAVAILABLE_TRACK_SECONDS, -) from .match import ( ADDRESS, CALLBACK, @@ -45,642 +31,17 @@ from .match import ( ) from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak from .storage import BluetoothStorage -from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_load_history_from_system -if TYPE_CHECKING: - from bleak.backends.device import BLEDevice - from bleak.backends.scanner import AdvertisementData - - -FILTER_UUIDS: Final = "UUIDs" - -APPLE_MFR_ID: Final = 76 -APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) -APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller -APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker -APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller -APPLE_START_BYTES_WANTED: Final = { - APPLE_IBEACON_START_BYTE, - APPLE_HOMEKIT_START_BYTE, - APPLE_HOMEKIT_NOTIFY_START_BYTE, - APPLE_DEVICE_ID_START_BYTE, -} - -MONOTONIC_TIME: Final = monotonic_time_coarse - _LOGGER = logging.getLogger(__name__) -def _dispatch_bleak_callback( - callback: AdvertisementDataCallback | None, - filters: dict[str, set[str]], - device: BLEDevice, - advertisement_data: AdvertisementData, -) -> None: - """Dispatch the callback.""" - if not callback: - # Callback destroyed right before being called, ignore - return - - if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection( - advertisement_data.service_uuids - ): - return - - try: - callback(device, advertisement_data) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in callback: %s", callback) - - -class BluetoothManager: - """Manage Bluetooth.""" - - __slots__ = ( - "_cancel_unavailable_tracking", - "_advertisement_tracker", - "_fallback_intervals", - "_intervals", - "_unavailable_callbacks", - "_connectable_unavailable_callbacks", - "_bleak_callbacks", - "_all_history", - "_connectable_history", - "_non_connectable_scanners", - "_connectable_scanners", - "_adapters", - "_sources", - "_bluetooth_adapters", - "storage", - "slot_manager", - "_debug", - "shutdown", - "_loop", - ) - - def __init__( - self, - bluetooth_adapters: BluetoothAdapters, - storage: BluetoothStorage, - slot_manager: BleakSlotManager, - ) -> None: - """Init bluetooth manager.""" - self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None - - self._advertisement_tracker = AdvertisementTracker() - self._fallback_intervals = self._advertisement_tracker.fallback_intervals - self._intervals = self._advertisement_tracker.intervals - - self._unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - self._connectable_unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - - self._bleak_callbacks: list[ - tuple[AdvertisementDataCallback, dict[str, set[str]]] - ] = [] - self._all_history: dict[str, BluetoothServiceInfoBleak] = {} - self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} - self._non_connectable_scanners: list[BaseHaScanner] = [] - self._connectable_scanners: list[BaseHaScanner] = [] - self._adapters: dict[str, AdapterDetails] = {} - self._sources: dict[str, BaseHaScanner] = {} - self._bluetooth_adapters = bluetooth_adapters - self.storage = storage - self.slot_manager = slot_manager - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - self.shutdown = False - self._loop: asyncio.AbstractEventLoop | None = None - - @property - def supports_passive_scan(self) -> bool: - """Return if passive scan is supported.""" - return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) - - def async_scanner_count(self, connectable: bool = True) -> int: - """Return the number of scanners.""" - if connectable: - return len(self._connectable_scanners) - return len(self._connectable_scanners) + len(self._non_connectable_scanners) - - async def async_diagnostics(self) -> dict[str, Any]: - """Diagnostics for the manager.""" - scanner_diagnostics = await asyncio.gather( - *[ - scanner.async_diagnostics() - for scanner in itertools.chain( - self._non_connectable_scanners, self._connectable_scanners - ) - ] - ) - return { - "adapters": self._adapters, - "slot_manager": self.slot_manager.diagnostics(), - "scanners": scanner_diagnostics, - "connectable_history": [ - service_info.as_dict() - for service_info in self._connectable_history.values() - ], - "all_history": [ - service_info.as_dict() for service_info in self._all_history.values() - ], - "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), - } - - def _find_adapter_by_address(self, address: str) -> str | None: - for adapter, details in self._adapters.items(): - if details[ADAPTER_ADDRESS] == address: - return adapter - return None - - def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: - """Return the scanner for a source.""" - return self._sources.get(source) - - async def async_get_bluetooth_adapters( - self, cached: bool = True - ) -> dict[str, AdapterDetails]: - """Get bluetooth adapters.""" - if not self._adapters or not cached: - if not cached: - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._adapters - - async def async_get_adapter_from_address(self, address: str) -> str | None: - """Get adapter from address.""" - if adapter := self._find_adapter_by_address(address): - return adapter - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._find_adapter_by_address(address) - - async def async_setup(self) -> None: - """Set up the bluetooth manager.""" - self._loop = asyncio.get_running_loop() - await self._bluetooth_adapters.refresh() - install_multiple_bleak_catcher() - self.async_setup_unavailable_tracking() - - def async_stop(self) -> None: - """Stop the Bluetooth integration at shutdown.""" - _LOGGER.debug("Stopping bluetooth manager") - self.shutdown = True - if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking.cancel() - self._cancel_unavailable_tracking = None - uninstall_multiple_bleak_catcher() - - def async_scanner_devices_by_address( - self, address: str, connectable: bool - ) -> list[BluetoothScannerDevice]: - """Get BluetoothScannerDevice by address.""" - if not connectable: - scanners: Iterable[BaseHaScanner] = itertools.chain( - self._connectable_scanners, self._non_connectable_scanners - ) - else: - scanners = self._connectable_scanners - return [ - BluetoothScannerDevice(scanner, *device_adv) - for scanner in scanners - if ( - device_adv := scanner.discovered_devices_and_advertisement_data.get( - address - ) - ) - ] - - def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: - """Return all of discovered addresses. - - Include addresses from all the scanners including duplicates. - """ - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._connectable_scanners - ) - if not connectable: - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._non_connectable_scanners - ) - - def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: - """Return all of combined best path to discovered from all the scanners.""" - histories = self._connectable_history if connectable else self._all_history - return [history.device for history in histories.values()] - - def async_setup_unavailable_tracking(self) -> None: - """Set up the unavailable tracking.""" - self._schedule_unavailable_tracking() - - def _schedule_unavailable_tracking(self) -> None: - """Schedule the unavailable tracking.""" - if TYPE_CHECKING: - assert self._loop is not None - loop = self._loop - self._cancel_unavailable_tracking = loop.call_at( - loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable - ) - - def _async_check_unavailable(self) -> None: - """Watch for unavailable devices and cleanup state history.""" - monotonic_now = MONOTONIC_TIME() - connectable_history = self._connectable_history - all_history = self._all_history - tracker = self._advertisement_tracker - intervals = tracker.intervals - - for connectable in (True, False): - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - history = connectable_history if connectable else all_history - disappeared = set(history).difference( - self._async_all_discovered_addresses(connectable) - ) - for address in disappeared: - if not connectable: - # - # For non-connectable devices we also check the device has exceeded - # the advertising interval before we mark it as unavailable - # since it may have gone to sleep and since we do not need an active - # connection to it we can only determine its availability - # by the lack of advertisements - if advertising_interval := ( - intervals.get(address) or self._fallback_intervals.get(address) - ): - advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS - else: - advertising_interval = ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ) - time_since_seen = monotonic_now - all_history[address].time - if time_since_seen <= advertising_interval: - continue - - # The second loop (connectable=False) is responsible for removing - # the device from all the interval tracking since it is no longer - # available for both connectable and non-connectable - tracker.async_remove_fallback_interval(address) - tracker.async_remove_address(address) - self._address_disappeared(address) - - service_info = history.pop(address) - - if not (callbacks := unavailable_callbacks.get(address)): - continue - - for callback in callbacks: - try: - callback(service_info) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in unavailable callback") - - self._schedule_unavailable_tracking() - - def _address_disappeared(self, address: str) -> None: - """Call when an address disappears from the stack. - - This method is intended to be overridden by subclasses. - """ - - def _prefer_previous_adv_from_different_source( - self, - old: BluetoothServiceInfoBleak, - new: BluetoothServiceInfoBleak, - ) -> bool: - """Prefer previous advertisement from a different source if it is better.""" - if new.time - old.time > ( - stale_seconds := self._intervals.get( - new.address, - self._fallback_intervals.get( - new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ), - ) - ): - # If the old advertisement is stale, any new advertisement is preferred - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (time elapsed:%s > stale" - " seconds:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.time - old.time, - stale_seconds, - ) - return False - if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( - old.rssi or NO_RSSI_VALUE - ): - # If new advertisement is RSSI_SWITCH_THRESHOLD more, - # the new one is preferred. - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" - " old rssi:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.rssi, - RSSI_SWITCH_THRESHOLD, - old.rssi, - ) - return False - return True - - def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: - """Handle a new advertisement from any scanner. - - Callbacks from all the scanners arrive here. - """ - - # Pre-filter noisy apple devices as they can account for 20-35% of the - # traffic on a typical network. - if ( - (manufacturer_data := service_info.manufacturer_data) - and APPLE_MFR_ID in manufacturer_data - and manufacturer_data[APPLE_MFR_ID][0] not in APPLE_START_BYTES_WANTED - and len(manufacturer_data) == 1 - and not service_info.service_data - ): - return - - address = service_info.device.address - all_history = self._all_history - connectable = service_info.connectable - connectable_history = self._connectable_history - old_connectable_service_info = connectable and connectable_history.get(address) - source = service_info.source - # This logic is complex due to the many combinations of scanners - # that are supported. - # - # We need to handle multiple connectable and non-connectable scanners - # and we need to handle the case where a device is connectable on one scanner - # but not on another. - # - # The device may also be connectable only by a scanner that has worse - # signal strength than a non-connectable scanner. - # - # all_history - the history of all advertisements from all scanners with the - # best advertisement from each scanner - # connectable_history - the history of all connectable advertisements from all - # scanners with the best advertisement from each - # connectable scanner - # - if ( - (old_service_info := all_history.get(address)) - and source != old_service_info.source - and (scanner := self._sources.get(old_service_info.source)) - and scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_service_info, service_info - ) - ): - # If we are rejecting the new advertisement and the device is connectable - # but not in the connectable history or the connectable source is the same - # as the new source, we need to add it to the connectable history - if connectable: - if old_connectable_service_info and ( - # If its the same as the preferred source, we are done - # as we know we prefer the old advertisement - # from the check above - (old_connectable_service_info is old_service_info) - # If the old connectable source is different from the preferred - # source, we need to check it as well to see if we prefer - # the old connectable advertisement - or ( - source != old_connectable_service_info.source - and ( - connectable_scanner := self._sources.get( - old_connectable_service_info.source - ) - ) - and connectable_scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info - ) - ) - ): - return - - connectable_history[address] = service_info - - return - - if connectable: - connectable_history[address] = service_info - - all_history[address] = service_info - - # Track advertisement intervals to determine when we need to - # switch adapters or mark a device as unavailable - tracker = self._advertisement_tracker - if (last_source := tracker.sources.get(address)) and last_source != source: - # Source changed, remove the old address from the tracker - tracker.async_remove_address(address) - if address not in tracker.intervals: - tracker.async_collect(service_info) - - # If the advertisement data is the same as the last time we saw it, we - # don't need to do anything else unless its connectable and we are missing - # connectable history for the device so we can make it available again - # after unavailable callbacks. - if ( - # Ensure its not a connectable device missing from connectable history - not (connectable and not old_connectable_service_info) - # Than check if advertisement data is the same - and old_service_info - and not ( - service_info.manufacturer_data != old_service_info.manufacturer_data - or service_info.service_data != old_service_info.service_data - or service_info.service_uuids != old_service_info.service_uuids - or service_info.name != old_service_info.name - ) - ): - return - - if not connectable and old_connectable_service_info: - # Since we have a connectable path and our BleakClient will - # route any connection attempts to the connectable path, we - # mark the service_info as connectable so that the callbacks - # will be called and the device can be discovered. - service_info = BluetoothServiceInfoBleak( - name=service_info.name, - address=service_info.address, - rssi=service_info.rssi, - manufacturer_data=service_info.manufacturer_data, - service_data=service_info.service_data, - service_uuids=service_info.service_uuids, - source=service_info.source, - device=service_info.device, - advertisement=service_info.advertisement, - connectable=True, - time=service_info.time, - ) - - if (connectable or old_connectable_service_info) and ( - bleak_callbacks := self._bleak_callbacks - ): - # Bleak callbacks must get a connectable device - device = service_info.device - advertisement_data = service_info.advertisement - for callback_filters in bleak_callbacks: - _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - - self._discover_service_info(service_info) - - def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: - """Discover a new service info. - - This method is intended to be overridden by subclasses. - """ - - def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: - """Describe a source.""" - if scanner := self._sources.get(service_info.source): - description = scanner.name - else: - description = service_info.source - if service_info.connectable: - description += " [connectable]" - return description - - def async_track_unavailable( - self, - callback: Callable[[BluetoothServiceInfoBleak], None], - address: str, - connectable: bool, - ) -> Callable[[], None]: - """Register a callback.""" - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - unavailable_callbacks.setdefault(address, []).append(callback) - - def _async_remove_callback() -> None: - unavailable_callbacks[address].remove(callback) - if not unavailable_callbacks[address]: - del unavailable_callbacks[address] - - return _async_remove_callback - - def async_ble_device_from_address( - self, address: str, connectable: bool - ) -> BLEDevice | None: - """Return the BLEDevice if present.""" - histories = self._connectable_history if connectable else self._all_history - if history := histories.get(address): - return history.device - return None - - def async_address_present(self, address: str, connectable: bool) -> bool: - """Return if the address is present.""" - histories = self._connectable_history if connectable else self._all_history - return address in histories - - def async_discovered_service_info( - self, connectable: bool - ) -> Iterable[BluetoothServiceInfoBleak]: - """Return all the discovered services info.""" - histories = self._connectable_history if connectable else self._all_history - return histories.values() - - def async_last_service_info( - self, address: str, connectable: bool - ) -> BluetoothServiceInfoBleak | None: - """Return the last service info for an address.""" - histories = self._connectable_history if connectable else self._all_history - return histories.get(address) - - def async_register_scanner( - self, - scanner: BaseHaScanner, - connectable: bool, - connection_slots: int | None = None, - ) -> CALLBACK_TYPE: - """Register a new scanner.""" - _LOGGER.debug("Registering scanner %s", scanner.name) - if connectable: - scanners = self._connectable_scanners - else: - scanners = self._non_connectable_scanners - - def _unregister_scanner() -> None: - _LOGGER.debug("Unregistering scanner %s", scanner.name) - self._advertisement_tracker.async_remove_source(scanner.source) - scanners.remove(scanner) - del self._sources[scanner.source] - if connection_slots: - self.slot_manager.remove_adapter(scanner.adapter) - - scanners.append(scanner) - self._sources[scanner.source] = scanner - if connection_slots: - self.slot_manager.register_adapter(scanner.adapter, connection_slots) - return _unregister_scanner - - def async_register_bleak_callback( - self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] - ) -> CALLBACK_TYPE: - """Register a callback.""" - callback_entry = (callback, filters) - self._bleak_callbacks.append(callback_entry) - - def _remove_callback() -> None: - self._bleak_callbacks.remove(callback_entry) - - # Replay the history since otherwise we miss devices - # that were already discovered before the callback was registered - # or we are in passive mode - for history in self._connectable_history.values(): - _dispatch_bleak_callback( - callback, filters, history.device, history.advertisement - ) - - return _remove_callback - - def async_release_connection_slot(self, device: BLEDevice) -> None: - """Release a connection slot.""" - self.slot_manager.release_slot(device) - - def async_allocate_connection_slot(self, device: BLEDevice) -> bool: - """Allocate a connection slot.""" - return self.slot_manager.allocate_slot(device) - - def async_get_learned_advertising_interval(self, address: str) -> float | None: - """Get the learned advertising interval for a MAC address.""" - return self._intervals.get(address) - - def async_get_fallback_availability_interval(self, address: str) -> float | None: - """Get the fallback availability timeout for a MAC address.""" - return self._fallback_intervals.get(address) - - def async_set_fallback_availability_interval( - self, address: str, interval: float - ) -> None: - """Override the fallback availability timeout for a MAC address.""" - self._fallback_intervals[address] = interval - - class HomeAssistantBluetoothManager(BluetoothManager): """Manage Bluetooth for Home Assistant.""" __slots__ = ( "hass", + "storage", "_integration_matcher", "_callback_index", "_cancel_logging_listener", @@ -696,13 +57,15 @@ class HomeAssistantBluetoothManager(BluetoothManager): ) -> None: """Init bluetooth manager.""" self.hass = hass + self.storage = storage self._integration_matcher = integration_matcher self._callback_index = BluetoothCallbackMatcherIndex() self._cancel_logging_listener: CALLBACK_TYPE | None = None - super().__init__(bluetooth_adapters, storage, slot_manager) + super().__init__(bluetooth_adapters, slot_manager) + self._async_logging_changed() @hass_callback - def _async_logging_changed(self, event: Event) -> None: + def _async_logging_changed(self, event: Event | None = None) -> None: """Handle logging change.""" self._debug = _LOGGER.isEnabledFor(logging.DEBUG) diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index a35c5be6daf..001a47767a1 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -3,18 +3,15 @@ from __future__ import annotations from collections.abc import Callable from enum import Enum -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING -from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak if TYPE_CHECKING: - from .manager import BluetoothManager + from .manager import HomeAssistantBluetoothManager -MANAGER: BluetoothManager | None = None - -MONOTONIC_TIME: Final = monotonic_time_coarse +MANAGER: HomeAssistantBluetoothManager | None = None BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py deleted file mode 100644 index d89f0b5b684..00000000000 --- a/homeassistant/components/bluetooth/usage.py +++ /dev/null @@ -1,51 +0,0 @@ -"""bluetooth usage utility to handle multiple instances.""" - -from __future__ import annotations - -import bleak -from bleak.backends.service import BleakGATTServiceCollection -import bleak_retry_connector - -from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper - -ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner -ORIGINAL_BLEAK_CLIENT = bleak.BleakClient -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( - bleak_retry_connector.BleakClientWithServiceCache -) -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient - - -def install_multiple_bleak_catcher() -> None: - """Wrap the bleak classes to return the shared instance. - - In case multiple instances are detected. - """ - bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] - bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501 - bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501 - - -def uninstall_multiple_bleak_catcher() -> None: - """Unwrap the bleak classes.""" - bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc] - bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE - ) - bleak_retry_connector.BleakClient = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT - ) - - -class HaBleakClientWithServiceCache(HaBleakClientWrapper): - """A BleakClient that implements service caching.""" - - def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: - """Set the cached services. - - No longer used since bleak 0.17+ has service caching built-in. - - This was only kept for backwards compatibility. - """ diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py deleted file mode 100644 index e3c08a035a8..00000000000 --- a/homeassistant/components/bluetooth/wrappers.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Bleak wrappers for bluetooth.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import contextlib -from dataclasses import dataclass -from functools import partial -import inspect -import logging -from typing import TYPE_CHECKING, Any, Final - -from bleak import BleakClient, BleakError -from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import ( - AdvertisementData, - AdvertisementDataCallback, - BaseBleakScanner, -) -from bleak_retry_connector import ( - NO_RSSI_VALUE, - ble_device_description, - clear_cache, - device_source, -) - -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback -from homeassistant.helpers.frame import report - -from . import models -from .base_scanner import BaseHaScanner, BluetoothScannerDevice - -FILTER_UUIDS: Final = "UUIDs" -_LOGGER = logging.getLogger(__name__) - - -if TYPE_CHECKING: - from .manager import BluetoothManager - - -@dataclass(slots=True) -class _HaWrappedBleakBackend: - """Wrap bleak backend to make it usable by Home Assistant.""" - - device: BLEDevice - scanner: BaseHaScanner - client: type[BaseBleakClient] - source: str | None - - -class HaBleakScannerWrapper(BaseBleakScanner): - """A wrapper that uses the single instance.""" - - def __init__( - self, - *args: Any, - detection_callback: AdvertisementDataCallback | None = None, - service_uuids: list[str] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the BleakScanner.""" - self._detection_cancel: CALLBACK_TYPE | None = None - self._mapped_filters: dict[str, set[str]] = {} - self._advertisement_data_callback: AdvertisementDataCallback | None = None - self._background_tasks: set[asyncio.Task] = set() - remapped_kwargs = { - "detection_callback": detection_callback, - "service_uuids": service_uuids or [], - **kwargs, - } - self._map_filters(*args, **remapped_kwargs) - super().__init__( - detection_callback=detection_callback, service_uuids=service_uuids or [] - ) - - @classmethod - async def discover(cls, timeout: float = 5.0, **kwargs: Any) -> list[BLEDevice]: - """Discover devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - async def stop(self, *args: Any, **kwargs: Any) -> None: - """Stop scanning for devices.""" - - async def start(self, *args: Any, **kwargs: Any) -> None: - """Start scanning for devices.""" - - def _map_filters(self, *args: Any, **kwargs: Any) -> bool: - """Map the filters.""" - mapped_filters = {} - if filters := kwargs.get("filters"): - if filter_uuids := filters.get(FILTER_UUIDS): - mapped_filters[FILTER_UUIDS] = set(filter_uuids) - else: - _LOGGER.warning("Only %s filters are supported", FILTER_UUIDS) - if service_uuids := kwargs.get("service_uuids"): - mapped_filters[FILTER_UUIDS] = set(service_uuids) - if mapped_filters == self._mapped_filters: - return False - self._mapped_filters = mapped_filters - return True - - def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None: - """Set the filters to use.""" - if self._map_filters(*args, **kwargs): - self._setup_detection_callback() - - def _cancel_callback(self) -> None: - """Cancel callback.""" - if self._detection_cancel: - self._detection_cancel() - self._detection_cancel = None - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - def register_detection_callback( - self, callback: AdvertisementDataCallback | None - ) -> Callable[[], None]: - """Register a detection callback. - - The callback is called when a device is discovered or has a property changed. - - This method takes the callback and registers it with the long running scanner. - """ - self._advertisement_data_callback = callback - self._setup_detection_callback() - assert self._detection_cancel is not None - return self._detection_cancel - - def _setup_detection_callback(self) -> None: - """Set up the detection callback.""" - if self._advertisement_data_callback is None: - return - callback = self._advertisement_data_callback - self._cancel_callback() - super().register_detection_callback(self._advertisement_data_callback) - assert models.MANAGER is not None - - if not inspect.iscoroutinefunction(callback): - detection_callback = callback - else: - - def detection_callback( - ble_device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - task = asyncio.create_task(callback(ble_device, advertisement_data)) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - - self._detection_cancel = models.MANAGER.async_register_bleak_callback( - detection_callback, self._mapped_filters - ) - - def __del__(self) -> None: - """Delete the BleakScanner.""" - if self._detection_cancel: - # Nothing to do if event loop is already closed - with contextlib.suppress(RuntimeError): - asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) - - -def _rssi_sorter_with_connection_failure_penalty( - device: BluetoothScannerDevice, - connection_failure_count: dict[BaseHaScanner, int], - rssi_diff: int, -) -> float: - """Get a sorted list of scanner, device, advertisement data. - - Adjusting for previous connection failures. - - When a connection fails, we want to try the next best adapter so we - apply a penalty to the RSSI value to make it less likely to be chosen - for every previous connection failure. - - We use the 51% of the RSSI difference between the first and second - best adapter as the penalty. This ensures we will always try the - best adapter twice before moving on to the next best adapter since - the first failure may be a transient service resolution issue. - """ - base_rssi = device.advertisement.rssi or NO_RSSI_VALUE - if connect_failures := connection_failure_count.get(device.scanner): - if connect_failures > 1 and not rssi_diff: - rssi_diff = 1 - return base_rssi - (rssi_diff * connect_failures * 0.51) - return base_rssi - - -class HaBleakClientWrapper(BleakClient): - """Wrap the BleakClient to ensure it does not shutdown our scanner. - - If an address is passed into BleakClient instead of a BLEDevice, - bleak will quietly start a new scanner under the hood to resolve - the address. This can cause a conflict with our scanner. We need - to handle translating the address to the BLEDevice in this case - to avoid the whole stack from getting stuck in an in progress state - when an integration does this. - """ - - def __init__( # pylint: disable=super-init-not-called - self, - address_or_ble_device: str | BLEDevice, - disconnected_callback: Callable[[BleakClient], None] | None = None, - *args: Any, - timeout: float = 10.0, - **kwargs: Any, - ) -> None: - """Initialize the BleakClient.""" - if isinstance(address_or_ble_device, BLEDevice): - self.__address = address_or_ble_device.address - else: - report( - "attempted to call BleakClient with an address instead of a BLEDevice", - exclude_integrations={"bluetooth"}, - error_if_core=False, - ) - self.__address = address_or_ble_device - self.__disconnected_callback = disconnected_callback - self.__timeout = timeout - self.__connect_failures: dict[BaseHaScanner, int] = {} - self._backend: BaseBleakClient | None = None # type: ignore[assignment] - - @property - def is_connected(self) -> bool: - """Return True if the client is connected to a device.""" - return self._backend is not None and self._backend.is_connected - - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - if self._backend is not None and hasattr(self._backend, "clear_cache"): - return await self._backend.clear_cache() # type: ignore[no-any-return] - return await clear_cache(self.__address) - - def set_disconnected_callback( - self, - callback: Callable[[BleakClient], None] | None, - **kwargs: Any, - ) -> None: - """Set the disconnect callback.""" - self.__disconnected_callback = callback - if self._backend: - self._backend.set_disconnected_callback( - self._make_disconnected_callback(callback), - **kwargs, - ) - - def _make_disconnected_callback( - self, callback: Callable[[BleakClient], None] | None - ) -> Callable[[], None] | None: - """Make the disconnected callback. - - https://github.com/hbldh/bleak/pull/1256 - The disconnected callback needs to get the top level - BleakClientWrapper instance, not the backend instance. - - The signature of the callback for the backend is: - Callable[[], None] - - To make this work we need to wrap the callback in a partial - that passes the BleakClientWrapper instance as the first - argument. - """ - return None if callback is None else partial(callback, self) - - async def connect(self, **kwargs: Any) -> bool: - """Connect to the specified GATT server.""" - assert models.MANAGER is not None - manager = models.MANAGER - if manager.shutdown: - raise BleakError("Bluetooth is already shutdown") - if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("%s: Looking for backend to connect", self.__address) - wrapped_backend = self._async_get_best_available_backend_and_device(manager) - device = wrapped_backend.device - scanner = wrapped_backend.scanner - self._backend = wrapped_backend.client( - device, - disconnected_callback=self._make_disconnected_callback( - self.__disconnected_callback - ), - timeout=self.__timeout, - ) - if debug_logging: - # Only lookup the description if we are going to log it - description = ble_device_description(device) - _, adv = scanner.discovered_devices_and_advertisement_data[device.address] - rssi = adv.rssi - _LOGGER.debug( - "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi - ) - connected = None - try: - connected = await super().connect(**kwargs) - finally: - # If we failed to connect and its a local adapter (no source) - # we release the connection slot - if not connected: - self.__connect_failures[scanner] = ( - self.__connect_failures.get(scanner, 0) + 1 - ) - if not wrapped_backend.source: - manager.async_release_connection_slot(device) - - if debug_logging: - _LOGGER.debug( - "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi - ) - return connected - - @hass_callback - def _async_get_backend_for_ble_device( - self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice - ) -> _HaWrappedBleakBackend | None: - """Get the backend for a BLEDevice.""" - if not (source := device_source(ble_device)): - # If client is not defined in details - # its the client for this platform - if not manager.async_allocate_connection_slot(ble_device): - return None - cls = get_platform_client_backend_type() - return _HaWrappedBleakBackend(ble_device, scanner, cls, source) - - # Make sure the backend can connect to the device - # as some backends have connection limits - if not scanner.connector or not scanner.connector.can_connect(): - return None - - return _HaWrappedBleakBackend( - ble_device, scanner, scanner.connector.client, source - ) - - @hass_callback - def _async_get_best_available_backend_and_device( - self, manager: BluetoothManager - ) -> _HaWrappedBleakBackend: - """Get a best available backend and device for the given address. - - This method will return the backend with the best rssi - that has a free connection slot. - """ - address = self.__address - devices = manager.async_scanner_devices_by_address(self.__address, True) - sorted_devices = sorted( - devices, - key=lambda device: device.advertisement.rssi or NO_RSSI_VALUE, - reverse=True, - ) - - # If we have connection failures we adjust the rssi sorting - # to prefer the adapter/scanner with the less failures so - # we don't keep trying to connect with an adapter - # that is failing - if self.__connect_failures and len(sorted_devices) > 1: - # We use the rssi diff between to the top two - # to adjust the rssi sorter so that each failure - # will reduce the rssi sorter by the diff amount - rssi_diff = ( - sorted_devices[0].advertisement.rssi - - sorted_devices[1].advertisement.rssi - ) - adjusted_rssi_sorter = partial( - _rssi_sorter_with_connection_failure_penalty, - connection_failure_count=self.__connect_failures, - rssi_diff=rssi_diff, - ) - sorted_devices = sorted( - devices, - key=adjusted_rssi_sorter, - reverse=True, - ) - - for device in sorted_devices: - if backend := self._async_get_backend_for_ble_device( - manager, device.scanner, device.ble_device - ): - return backend - - raise BleakError( - "No backend with an available connection slot that can reach address" - f" {address} was found" - ) - - async def disconnect(self) -> bool: - """Disconnect from the device.""" - if self._backend is None: - return True - return await self._backend.disconnect() diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 5261e7371f3..5ad4b5a6c31 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS -from habluetooth import BaseHaScanner, BluetoothManager +from habluetooth import BaseHaScanner, BluetoothManager, get_manager from homeassistant.components.bluetooth import ( DOMAIN, @@ -18,7 +18,6 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, - models, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,9 +59,6 @@ BLE_DEVICE_DEFAULTS = { def patch_bluetooth_time(mock_time: float) -> None: """Patch the bluetooth time.""" with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=mock_time, - ), patch( "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time ), patch( "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time @@ -104,7 +100,7 @@ def generate_ble_device( def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" - return models.MANAGER + return get_manager() def inject_advertisement( diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 8681287baa2..190b05e60e8 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -1,7 +1,6 @@ """Tests for the Bluetooth integration advertisement tracking.""" from datetime import timedelta import time -from unittest.mock import patch from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest @@ -25,6 +24,7 @@ from . import ( generate_ble_device, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed @@ -70,9 +70,8 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( ) monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -123,9 +122,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -189,9 +187,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -245,9 +242,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -321,9 +317,8 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -402,9 +397,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -415,9 +409,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c cancel_scanner() # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -427,9 +420,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c assert switchbot_device_went_unavailable is False # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, @@ -484,9 +476,8 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou ) monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 63ff735ca43..52624e67996 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -8,6 +8,7 @@ from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS from habluetooth import scanner +from habluetooth.wrappers import HaBleakScannerWrapper import pytest from homeassistant.components import bluetooth @@ -35,7 +36,6 @@ from homeassistant.components.bluetooth.match import ( SERVICE_DATA_UUID, SERVICE_UUID, ) -from homeassistant.components.bluetooth.wrappers import HaBleakScannerWrapper from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 361f0cd008f..33683977ef0 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -7,6 +7,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from habluetooth.manager import FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS import pytest from homeassistant.components import bluetooth @@ -31,9 +32,6 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) -from homeassistant.components.bluetooth.manager import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, -) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -48,6 +46,7 @@ from . import ( inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture @@ -962,9 +961,8 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( return_value=[{"flow_id": "mock_flow_id"}], ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" - ) as mock_async_abort, patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + ) as mock_async_abort, patch_bluetooth_time( + monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1105,9 +1103,8 @@ async def test_set_fallback_interval_small( ) monotonic_now = start_monotonic_time + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1170,9 +1167,8 @@ async def test_set_fallback_interval_big( # Check that device hasn't expired after a day monotonic_now = start_monotonic_time + 86400 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1184,9 +1180,8 @@ async def test_set_fallback_interval_big( # Try again after it has expired monotonic_now = start_monotonic_time + 604800 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 8cffbe685b6..7499f312cef 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -7,6 +7,7 @@ import bleak from bleak import BleakError from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper import pytest from homeassistant.components.bluetooth import ( @@ -14,10 +15,6 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, HomeAssistantRemoteScanner, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) from homeassistant.core import HomeAssistant from . import ( diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 86f0ee4b5de..b6e50ebc565 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -22,7 +22,11 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import inject_bluetooth_service_info, patch_all_discovered_devices +from . import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) from tests.common import async_fire_time_changed @@ -159,10 +163,9 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] + ): async_fire_time_changed( hass, dt_util.utcnow() @@ -176,9 +179,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 8cc76e01d8c..345c4b62b7e 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -48,6 +48,7 @@ from . import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) from tests.common import ( @@ -471,9 +472,8 @@ async def test_unavailable_after_no_data( assert processor.available is True monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -490,9 +490,8 @@ async def test_unavailable_after_no_data( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 12bdba66d75..0edff02aa0e 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -2,17 +2,12 @@ from unittest.mock import patch import bleak -import bleak_retry_connector -import pytest - -from homeassistant.components.bluetooth.usage import ( +from habluetooth.usage import ( install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper + from homeassistant.core import HomeAssistant from . import generate_ble_device @@ -57,47 +52,3 @@ async def test_wrapping_bleak_client( instance = bleak.BleakClient(MOCK_BLE_DEVICE) assert not isinstance(instance, HaBleakClientWrapper) - - -async def test_bleak_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClient.""" - install_multiple_bleak_catcher() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text - - -async def test_bleak_retry_connector_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClientWithServiceCache.""" - install_multiple_bleak_catcher() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index d3c2e1b54db..1d294d90d76 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -2,30 +2,40 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import contextmanager from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakError +from habluetooth.usage import ( + install_multiple_bleak_catcher, + uninstall_multiple_bleak_catcher, +) import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantBluetoothManager, HomeAssistantRemoteScanner, async_get_advertisement_callback, ) -from homeassistant.components.bluetooth.usage import ( - install_multiple_bleak_catcher, - uninstall_multiple_bleak_catcher, -) from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device +@contextmanager +def mock_shutdown(manager: HomeAssistantBluetoothManager) -> None: + """Mock shutdown of the HomeAssistantBluetoothManager.""" + manager.shutdown = True + yield + manager.shutdown = False + + class FakeScanner(HomeAssistantRemoteScanner): """Fake scanner.""" @@ -133,7 +143,7 @@ def install_bleak_catcher_fixture(): def mock_platform_client_fixture(): """Fixture that mocks the platform client.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): yield @@ -143,7 +153,7 @@ def mock_platform_client_fixture(): def mock_platform_client_that_fails_to_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsToConnect, ): yield @@ -153,7 +163,7 @@ def mock_platform_client_that_fails_to_connect_fixture(): def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientRaisesOnConnect, ): yield @@ -332,27 +342,27 @@ async def test_we_switch_adapters_on_failure( return True with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False # After two tries we should switch to hci1 with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True # ..and we remember that hci1 works as long as the client doesn't change with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True @@ -361,7 +371,7 @@ async def test_we_switch_adapters_on_failure( client = bleak.BleakClient(ble_device) with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False @@ -382,7 +392,7 @@ async def test_raise_after_shutdown( hass ) # hci0 has 2 slots, hci1 has 1 slot - with patch.object(manager, "shutdown", True): + with mock_shutdown(manager): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(BleakError, match="shutdown"): diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 168988e510f..c38bec3ba44 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -236,10 +236,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -290,10 +287,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -344,10 +338,7 @@ async def test_sleepy_device_restores_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index c1f8e26ccb2..0b6e7a42cfb 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -1150,10 +1150,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1206,10 +1203,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1262,10 +1256,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 185ae2404da..5e7ca299fb6 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -1,7 +1,6 @@ """Test the Govee BLE sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -27,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -112,9 +112,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -139,9 +138,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: assert primary_temp_sensor.state == "1.0" # Fastforward time without BLE advertisements - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 49de6db6e13..b48ccad2fe2 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -24,6 +23,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -63,9 +63,8 @@ async def test_sensors( # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -114,9 +113,8 @@ async def test_sensors_io_series_4( # Fast-forward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py index df9929293a1..967f422872b 100644 --- a/tests/components/private_ble_device/__init__.py +++ b/tests/components/private_ble_device/__init__.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -16,6 +15,7 @@ from tests.components.bluetooth import ( generate_advertisement_data, generate_ble_device, inject_bluetooth_service_info_bleak, + patch_bluetooth_time, ) MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" @@ -70,9 +70,8 @@ async def async_inject_broadcast( async def async_move_time_forwards(hass: HomeAssistant, offset: float): """Mock time advancing from now to now+offset.""" - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=time.monotonic() + offset, + with patch_bluetooth_time( + time.monotonic() + offset, ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index e35643d7626..a5175789909 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -81,7 +81,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "90" + assert state.state == "90.0" # Learned broadcast interval takes over from fallback interval @@ -104,4 +104,4 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" diff --git a/tests/components/qingping/test_binary_sensor.py b/tests/components/qingping/test_binary_sensor.py index 9b83cd8c590..f201b3b55ff 100644 --- a/tests/components/qingping/test_binary_sensor.py +++ b/tests/components/qingping/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping binary sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -17,6 +16,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -72,9 +72,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/qingping/test_sensor.py b/tests/components/qingping/test_sensor.py index 2fedbba9e5c..12e3ec85c52 100644 --- a/tests/components/qingping/test_sensor.py +++ b/tests/components/qingping/test_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -82,9 +82,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index e00b626b20b..2e7a0867309 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -1,7 +1,6 @@ """Test the SensorPush sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -55,9 +55,8 @@ async def test_sensors(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 32d1fea7f62..14ea3e44af8 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -23,6 +22,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -294,9 +294,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -347,9 +346,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -400,9 +398,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index b0ddd99a7c2..ceca08a68ee 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -1,7 +1,6 @@ """Test Xiaomi BLE sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -28,6 +27,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -692,9 +692,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -739,9 +738,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -788,9 +786,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, From a187a39f0b27acafc04b9444f461a58e2847be30 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Dec 2023 22:06:16 +0100 Subject: [PATCH 310/927] Add config flow to Suez water (#104730) * Add config flow to Suez water * fix tests * Complete coverage * Change version to 2024.7 * Fix final test * Add issue when import is successful * Move hassdata * Do unique_id * Remove import issue when entry already exists * Remove import issue when entry already exists --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/suez_water/__init__.py | 49 +++- .../components/suez_water/config_flow.py | 166 +++++++++++++ homeassistant/components/suez_water/const.py | 5 + .../components/suez_water/manifest.json | 1 + homeassistant/components/suez_water/sensor.py | 33 +-- .../components/suez_water/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/suez_water/__init__.py | 1 + tests/components/suez_water/conftest.py | 14 ++ .../components/suez_water/test_config_flow.py | 223 ++++++++++++++++++ 14 files changed, 519 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/suez_water/config_flow.py create mode 100644 homeassistant/components/suez_water/const.py create mode 100644 homeassistant/components/suez_water/strings.json create mode 100644 tests/components/suez_water/__init__.py create mode 100644 tests/components/suez_water/conftest.py create mode 100644 tests/components/suez_water/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index c012c8e686e..7c74ed57505 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1233,7 +1233,8 @@ omit = homeassistant/components/stream/hls.py homeassistant/components/stream/worker.py homeassistant/components/streamlabswater/* - homeassistant/components/suez_water/* + homeassistant/components/suez_water/__init__.py + homeassistant/components/suez_water/sensor.py homeassistant/components/supervisord/sensor.py homeassistant/components/supla/* homeassistant/components/surepetcare/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index dad0d51ad79..17b5909471d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1251,6 +1251,7 @@ build.json @home-assistant/supervisor /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii +/tests/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig /homeassistant/components/sunweg/ @rokam diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index a2d07a8d0a4..66c3981705c 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -1 +1,48 @@ -"""France Suez Water integration.""" +"""The Suez Water integration.""" +from __future__ import annotations + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_COUNTER_ID, DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Suez Water from a config entry.""" + + def get_client() -> SuezClient: + try: + client = SuezClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + return client + except PySuezError: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = await hass.async_add_executor_job(get_client) + + 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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py new file mode 100644 index 00000000000..1dd79c017e0 --- /dev/null +++ b/homeassistant/components/suez_water/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Suez Water integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pysuez import SuezClient +from pysuez.client import PySuezError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_COUNTER_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"} +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTER_ID): str, + } +) + + +def validate_input(data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + try: + client = SuezClient( + data[CONF_USERNAME], + data[CONF_PASSWORD], + data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise InvalidAuth + except PySuezError: + raise CannotConnect + + +class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Suez Water.""" + + VERSION = 1 + + 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: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + try: + self._abort_if_unique_id_configured() + except AbortFlow as err: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Suez Water", + }, + ) + raise err + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_invalid_auth", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_invalid_auth", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + return self.async_abort(reason="invalid_auth") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_unknown", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_unknown", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + return self.async_abort(reason="unknown") + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Suez Water", + }, + ) + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py new file mode 100644 index 00000000000..7afc0d3ce3e --- /dev/null +++ b/homeassistant/components/suez_water/const.py @@ -0,0 +1,5 @@ +"""Constants for the Suez Water integration.""" + +DOMAIN = "suez_water" + +CONF_COUNTER_ID = "counter_id" diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 3da91c4aa52..4503d7a1119 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -2,6 +2,7 @@ "domain": "suez_water", "name": "Suez Water", "codeowners": ["@ooii"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index d0c1bba211e..fc5b804137d 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -13,18 +13,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import CONF_COUNTER_ID, DOMAIN + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(hours=12) -CONF_COUNTER_ID = "counter_id" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -41,21 +42,23 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - counter_id = config[CONF_COUNTER_ID] - try: - client = SuezClient(username, password, counter_id, provider=None) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - if not client.check_credentials(): - _LOGGER.warning("Wrong username and/or password") - return - except PySuezError: - _LOGGER.warning("Unable to create Suez Client") - return - - add_entities([SuezSensor(client)], True) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Suez Water sensor from a config entry.""" + client = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezSensor(client)], True) class SuezSensor(SensorEntity): diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json new file mode 100644 index 00000000000..09df3ead17f --- /dev/null +++ b/homeassistant/components/suez_water/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "counter_id": "Counter id" + } + } + }, + "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%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Suez water works and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 975bfc60688..5936ac01b68 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -471,6 +471,7 @@ FLOWS = { "stookalert", "stookwijzer", "subaru", + "suez_water", "sun", "sunweg", "surepetcare", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33e2229eb2e..af822143d50 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5537,7 +5537,7 @@ "suez_water": { "name": "Suez Water", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "sun": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ca0e164808..10fe491ec04 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1602,6 +1602,9 @@ pyspcwebgw==0.7.0 # homeassistant.components.squeezebox pysqueezebox==0.7.1 +# homeassistant.components.suez_water +pysuez==0.2.0 + # homeassistant.components.switchbee pyswitchbee==1.8.0 diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py new file mode 100644 index 00000000000..4605e06344a --- /dev/null +++ b/tests/components/suez_water/__init__.py @@ -0,0 +1 @@ +"""Tests for the Suez Water integration.""" diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py new file mode 100644 index 00000000000..8a67cfe97d7 --- /dev/null +++ b/tests/components/suez_water/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Suez Water 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.suez_water.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py new file mode 100644 index 00000000000..265598e5c64 --- /dev/null +++ b/tests/components/suez_water/test_config_flow.py @@ -0,0 +1,223 @@ +"""Test the Suez Water config flow.""" +from unittest.mock import AsyncMock, patch + +from pysuez.client import PySuezError +import pytest + +from homeassistant import config_entries +from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +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.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + 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.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test we abort when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_form_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test import flow.""" + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + assert len(issue_registry.issues) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_import_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + reason: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we handle errors while importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert len(issue_registry.issues) == 1 + + +async def test_importing_invalid_auth( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test we handle invalid auth when importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" + assert len(issue_registry.issues) == 1 + + +async def test_import_already_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(issue_registry.issues) == 1 From 662e19999d0ed5b0baf4414b38458c3706babb3e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 11 Dec 2023 22:28:04 +0100 Subject: [PATCH 311/927] Add Fastdotcom DataUpdateCoordinator (#104839) * Adding DataUpdateCoordinator * Updating and adding test cases * Optimizing test * Fix typing * Prevent speedtest at startup * Removing typing on Coordinator * Update homeassistant/components/fastdotcom/coordinator.py Co-authored-by: G Johansson * Putting back typing * Update homeassistant/components/fastdotcom/__init__.py Co-authored-by: G Johansson * Adding proper StateType typing * Fix linting * Stricter typing * Creating proper test case for coordinator * Fixing typo * Patching librbary * Adding unavailable state test * Putting back in asserts * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Coordinator workable proposal * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Working test cases * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Update tests/components/fastdotcom/test_coordinator.py Co-authored-by: G Johansson * Fixing tests and context * Fix the freezer interval to 59 minutes * Fix test --------- Co-authored-by: G Johansson --- .../components/fastdotcom/__init__.py | 55 ++++++------------- .../components/fastdotcom/coordinator.py | 31 +++++++++++ homeassistant/components/fastdotcom/sensor.py | 53 +++++++----------- .../components/fastdotcom/test_config_flow.py | 5 +- .../components/fastdotcom/test_coordinator.py | 54 ++++++++++++++++++ 5 files changed, 122 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/fastdotcom/coordinator.py create mode 100644 tests/components/fastdotcom/test_coordinator.py diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 2fe5b3ccafc..e872c3f501d 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,22 +1,18 @@ """Support for testing internet speed via Fast.com.""" from __future__ import annotations -from datetime import datetime, timedelta import logging -from typing import Any -from fastdotcom import fast_com import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, Event, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import CONF_MANUAL, DATA_UPDATED, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .coordinator import FastdotcomDataUpdateCoordindator _LOGGER = logging.getLogger(__name__) @@ -48,21 +44,20 @@ async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Fast.com component.""" - data = hass.data[DOMAIN] = SpeedtestData(hass) + """Set up Fast.com from a config entry.""" + coordinator = FastdotcomDataUpdateCoordindator(hass) - entry.async_on_unload( - async_track_time_interval(hass, data.update, timedelta(hours=DEFAULT_INTERVAL)) - ) - # Run an initial update to get a starting state - await data.update() + async def _request_refresh(event: Event) -> None: + """Request a refresh.""" + await coordinator.async_request_refresh() - async def update(service_call: ServiceCall | None = None) -> None: - """Service call to manually update the data.""" - await data.update() - - hass.services.async_register(DOMAIN, "speedtest", update) + if hass.state == CoreState.running: + await coordinator.async_config_entry_first_refresh() + else: + # Don't start the speedtest when HA is starting up + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups( entry, PLATFORMS, @@ -73,23 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Fast.com config entry.""" + hass.services.async_remove(DOMAIN, "speedtest") if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SpeedtestData: - """Get the latest data from Fast.com.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the data object.""" - self.data: dict[str, Any] | None = None - self._hass = hass - - async def update(self, now: datetime | None = None) -> None: - """Get the latest data from fast.com.""" - _LOGGER.debug("Executing Fast.com speedtest") - fast_com_data = await self._hass.async_add_executor_job(fast_com) - self.data = {"download": fast_com_data} - _LOGGER.debug("Fast.com speedtest finished, with mbit/s: %s", fast_com_data) - dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py new file mode 100644 index 00000000000..692a85d2eda --- /dev/null +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Fast.com integration.""" +from __future__ import annotations + +from datetime import timedelta + +from fastdotcom import fast_com + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER + + +class FastdotcomDataUpdateCoordindator(DataUpdateCoordinator[float]): + """Class to manage fetching Fast.com data API.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the coordinator for Fast.com.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=DEFAULT_INTERVAL), + ) + + async def _async_update_data(self) -> float: + """Run an executor job to retrieve Fast.com data.""" + try: + return await self.hass.async_add_executor_job(fast_com) + except Exception as exc: + raise UpdateFailed(f"Error communicating with Fast.com: {exc}") from exc diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 939ab4a40e5..b82b20defb5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,8 +1,6 @@ """Support for Fast.com internet speed testing sensor.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -10,12 +8,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN +from .const import DOMAIN +from .coordinator import FastdotcomDataUpdateCoordindator async def async_setup_entry( @@ -24,11 +22,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor(RestoreEntity, SensorEntity): +class SpeedtestSensor( + CoordinatorEntity[FastdotcomDataUpdateCoordindator], SensorEntity +): """Implementation of a Fast.com sensor.""" _attr_name = "Fast.com Download" @@ -38,31 +38,16 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: + def __init__( + self, entry_id: str, coordinator: FastdotcomDataUpdateCoordindator + ) -> None: """Initialize the sensor.""" - self._speedtest_data = speedtest_data + super().__init__(coordinator) self._attr_unique_id = entry_id - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) - ) - - if not (state := await self.async_get_last_state()): - return - self._attr_native_value = state.state - - def update(self) -> None: - """Get the latest data and update the states.""" - if (data := self._speedtest_data.data) is None: # type: ignore[attr-defined] - return - self._attr_native_value = data["download"] - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) + @property + def native_value( + self, + ) -> float: + """Return the state of the sensor.""" + return self.coordinator.data diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py index 4314a7688d8..17e75935dae 100644 --- a/tests/components/fastdotcom/test_config_flow.py +++ b/tests/components/fastdotcom/test_config_flow.py @@ -57,10 +57,7 @@ async def test_single_instance_allowed( async def test_import_flow_success(hass: HomeAssistant) -> None: """Test import flow.""" - with patch( - "homeassistant.components.fastdotcom.__init__.SpeedtestData", - return_value={"download": "50"}, - ), patch("homeassistant.components.fastdotcom.sensor.SpeedtestSensor"): + with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py new file mode 100644 index 00000000000..254301950fb --- /dev/null +++ b/tests/components/fastdotcom/test_coordinator.py @@ -0,0 +1,54 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DOMAIN +from homeassistant.components.fastdotcom.coordinator import DEFAULT_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=10.0 + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state == "10.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", + side_effect=Exception("Test error"), + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state is STATE_UNAVAILABLE From bf939298262d6d7ee30dddc92fe4c769f513f5c4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 11 Dec 2023 22:58:56 +0100 Subject: [PATCH 312/927] Add support for Shelly Gen3 devices (#104874) * Add support for Gen3 devices * Add RPC_GENERATIONS const * Add gen3 to tests * More tests * Add BLOCK_GENERATIONS const * Use *_GENERATIONS constants from aioshelly --- homeassistant/components/shelly/__init__.py | 5 +++-- .../components/shelly/binary_sensor.py | 4 +++- homeassistant/components/shelly/button.py | 4 +++- homeassistant/components/shelly/climate.py | 3 ++- homeassistant/components/shelly/config_flow.py | 17 ++++++++++------- homeassistant/components/shelly/cover.py | 3 ++- homeassistant/components/shelly/event.py | 4 ++-- homeassistant/components/shelly/light.py | 4 ++-- homeassistant/components/shelly/sensor.py | 3 ++- homeassistant/components/shelly/switch.py | 4 ++-- homeassistant/components/shelly/update.py | 3 ++- homeassistant/components/shelly/utils.py | 6 ++++-- tests/components/shelly/test_config_flow.py | 14 ++++++++++++++ tests/components/shelly/test_init.py | 8 ++++---- 14 files changed, 55 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 553d32f8e48..0fab86f7f4f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -6,6 +6,7 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -123,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: get_entry_data(hass)[entry.entry_id] = ShellyEntryData() - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: return await _async_setup_rpc_entry(hass, entry) return await _async_setup_block_entry(hass, entry) @@ -313,7 +314,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not entry.data.get(CONF_SLEEP_PERIOD): platforms = RPC_PLATFORMS - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: if unload_ok := await hass.config_entries.async_unload_platforms( entry, platforms ): diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index a5889cd11a7..caed52279da 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Final, cast +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -224,7 +226,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index edc33c9a8a0..e5cc6b6580b 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -5,6 +5,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -126,7 +128,7 @@ async def async_setup_entry( return async_migrate_unique_ids(entity_entry, coordinator) coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc else: coordinator = get_entry_data(hass)[config_entry.entry_id].block diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 6a592c904f6..9ac603a7fb0 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -6,6 +6,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -51,7 +52,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) coordinator = get_entry_data(hass)[config_entry.entry_id].block diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 98233d27b22..68b0f1f8ccc 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info +from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, @@ -66,7 +67,9 @@ async def validate_input( """ options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - if get_info_gen(info) == 2: + gen = get_info_gen(info) + + if gen in RPC_GENERATIONS: ws_context = await get_ws_context(hass) rpc_device = await RpcDevice.create( async_get_clientsession(hass), @@ -81,7 +84,7 @@ async def validate_input( "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, "model": rpc_device.shelly.get("model"), - "gen": 2, + "gen": gen, } # Gen1 @@ -96,7 +99,7 @@ async def validate_input( "title": block_device.name, CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), "model": block_device.model, - "gen": 1, + "gen": gen, } @@ -165,7 +168,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the credentials step.""" errors: dict[str, str] = {} if user_input is not None: - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( @@ -194,7 +197,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): else: user_input = {} - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: schema = { vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, } @@ -331,7 +334,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - if self.entry.data.get("gen", 1) == 1: + if self.entry.data.get("gen", 1) in BLOCK_GENERATIONS: schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -360,7 +363,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return ( - config_entry.data.get("gen") == 2 + config_entry.data.get("gen") in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 95f387f8f97..4390790c794 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.cover import ( ATTR_POSITION, @@ -26,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up covers for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index af323c82a24..e93303d7191 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import Block -from aioshelly.const import MODEL_I3 +from aioshelly.const import MODEL_I3, RPC_GENERATIONS from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -80,7 +80,7 @@ async def async_setup_entry( coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc if TYPE_CHECKING: assert coordinator diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 2dfc5b497b1..7e49dc78e4d 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_BULB +from aioshelly.const import MODEL_BULB, RPC_GENERATIONS from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 99ccd9ab2ff..4518135214c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Final, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.sensor import ( RestoreSensor, @@ -925,7 +926,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5a398182e4d..5ef39cd33af 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS +from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -49,7 +49,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 9e52a292108..975b61e631a 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import logging from typing import Any, Final, cast +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.update import ( @@ -119,7 +120,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for Shelly component.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b53e3153a09..7d475bf5ef8 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -7,12 +7,14 @@ from typing import Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice from aioshelly.const import ( + BLOCK_GENERATIONS, MODEL_1L, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, MODEL_I3, MODEL_NAMES, + RPC_GENERATIONS, ) from aioshelly.rpc_device import RpcDevice, WsServer @@ -284,7 +286,7 @@ def get_info_gen(info: dict[str, Any]) -> int: def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" - if get_info_gen(info) == 2: + if get_info_gen(info) in RPC_GENERATIONS: return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -420,4 +422,4 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None - return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL + return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index c7ac472ada4..1bccd3570cf 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -55,6 +55,7 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( [ (1, MODEL_1), (2, MODEL_PLUS_2PM), + (3, MODEL_PLUS_2PM), ], ) async def test_form( @@ -109,6 +110,12 @@ async def test_form( {"password": "test2 password"}, "admin", ), + ( + 3, + MODEL_PLUS_2PM, + {"password": "test2 password"}, + "admin", + ), ], ) async def test_form_auth( @@ -465,6 +472,11 @@ async def test_form_auth_errors_test_connection_gen2( MODEL_PLUS_2PM, {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2}, ), + ( + 3, + MODEL_PLUS_2PM, + {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 3}, + ), ], ) async def test_zeroconf( @@ -742,6 +754,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_successful( @@ -780,6 +793,7 @@ async def test_reauth_successful( [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None: diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 2ead9cba198..8f6599b39e4 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ async def test_custom_coap_port( assert "Starting CoAP context with UDP port 7632" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_shared_device_mac( hass: HomeAssistant, gen, @@ -74,7 +74,7 @@ async def test_setup_entry_not_shelly( assert "probably comes from a custom integration" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_connection_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -90,7 +90,7 @@ async def test_device_connection_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_mac_mismatch_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -106,7 +106,7 @@ async def test_mac_mismatch_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_auth_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: From d4cf0490161a63511b8eb404496c97dd1cd3893a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Dec 2023 23:10:11 +0100 Subject: [PATCH 313/927] Remove unneeded class _EntityDescriptionBase (#105518) --- homeassistant/helpers/entity.py | 13 +------------ homeassistant/util/frozen_dataclass_compat.py | 6 ++++++ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 6446a4fe6d6..dad0e2e00f3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -23,7 +23,6 @@ from typing import ( final, ) -from typing_extensions import dataclass_transform import voluptuous as vol from homeassistant.backports.functools import cached_property @@ -220,17 +219,7 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass_transform( - field_specifiers=(dataclasses.field, dataclasses.Field), - kw_only_default=True, # Set to allow setting kw_only in child classes -) -class _EntityDescriptionBase: - """Add PEP 681 decorator (dataclass transform).""" - - -class EntityDescription( - _EntityDescriptionBase, metaclass=FrozenOrThawed, frozen_or_thawed=True -): +class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): """A class that describes Home Assistant entities.""" # This is the key identifier for this entity diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 96053844ab5..e62e0a34cf1 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -9,6 +9,8 @@ import dataclasses import sys from typing import Any +from typing_extensions import dataclass_transform + def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: """Return a list of dataclass fields. @@ -41,6 +43,10 @@ def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: return [(field.name, field.type, field) for field in cls_fields] +@dataclass_transform( + field_specifiers=(dataclasses.field, dataclasses.Field), + kw_only_default=True, # Set to allow setting kw_only in child classes +) class FrozenOrThawed(type): """Metaclass which which makes classes which behave like a dataclass. From bf9c2a08b79e62965ef338d6fd9274ff65556b71 Mon Sep 17 00:00:00 2001 From: "Julien \"_FrnchFrgg_\" Rivaud" Date: Tue, 12 Dec 2023 04:42:52 +0100 Subject: [PATCH 314/927] Bump caldav to 1.3.8 (#105508) * Bump caldav to 1.3.8 1.3.8 fixes a bug where duplicate STATUS properties would be emitted for a single VTODO depending on the case of the arguments used. That bug meant that even though that is the intended API usage, passing lowercase for the status argument name would be rejected by caldav servers checking conformance with the spec which forbids duplicate STATUS. This in turn prevented HomeAssistant to add new items to a caldav todo list. Bump the requirements to 1.3.8 to repair that feature * Update global requirements --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index a7365515758..619523ae7a1 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.6"] + "requirements": ["caldav==1.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7b76caf4c28..90b3435979b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -607,7 +607,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.circuit circuit-webhook==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10fe491ec04..c4568fdd26e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ bthome-ble==3.2.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.coinbase coinbase==2.1.0 From 8922c9325949f8ab8aa69d827be5ee1d11f8292f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 12 Dec 2023 16:30:54 +1000 Subject: [PATCH 315/927] Improve tests in Tessie (#105430) --- .../components/tessie/config_flow.py | 2 +- tests/components/tessie/conftest.py | 28 +++++ tests/components/tessie/test_config_flow.py | 116 ++++-------------- tests/components/tessie/test_coordinator.py | 12 -- 4 files changed, 51 insertions(+), 107 deletions(-) create mode 100644 tests/components/tessie/conftest.py diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index 4379a810309..3e3207b264b 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -81,7 +81,7 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" else: errors["base"] = "unknown" except ClientConnectionError: diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py new file mode 100644 index 00000000000..c7a344d54c5 --- /dev/null +++ b/tests/components/tessie/conftest.py @@ -0,0 +1,28 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .common import TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + return_value=TEST_VEHICLE_STATE_ONLINE, + ) as mock_get_state: + yield mock_get_state + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index d1977a13193..182468e200c 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -15,24 +15,13 @@ from .common import ( ERROR_CONNECTION, ERROR_UNKNOWN, TEST_CONFIG, - TEST_STATE_OF_ALL_VEHICLES, setup_platform, ) from tests.common import MockConfigEntry -@pytest.fixture -def mock_get_state_of_all_vehicles(): - """Mock get_state_of_all_vehicles function.""" - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, - ) as mock_get_state_of_all_vehicles: - yield mock_get_state_of_all_vehicles - - -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: """Test we get the form.""" result1 = await hass.config_entries.flow.async_init( @@ -42,9 +31,6 @@ async def test_form(hass: HomeAssistant) -> None: assert not result1["errors"] with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, - ) as mock_get_state_of_all_vehicles, patch( "homeassistant.components.tessie.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -61,96 +47,38 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == TEST_CONFIG -async def test_form_invalid_access_token(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, side_effect, error, mock_get_state_of_all_vehicles +) -> None: """Test invalid auth is handled.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - side_effect=ERROR_AUTH, - ): - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - TEST_CONFIG, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} - - # Complete the flow - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - TEST_CONFIG, - ) - assert result3["type"] == FlowResultType.CREATE_ENTRY - - -async def test_form_invalid_response(hass: HomeAssistant) -> None: - """Test invalid auth is handled.""" - - result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + mock_get_state_of_all_vehicles.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, ) - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - side_effect=ERROR_UNKNOWN, - ): - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - TEST_CONFIG, - ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == error # Complete the flow - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - TEST_CONFIG, - ) - assert result3["type"] == FlowResultType.CREATE_ENTRY - - -async def test_form_network_issue(hass: HomeAssistant) -> None: - """Test network issues are handled.""" - - result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, ) - - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - side_effect=ERROR_CONNECTION, - ): - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - TEST_CONFIG, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - # Complete the flow - with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - TEST_CONFIG, - ) assert result3["type"] == FlowResultType.CREATE_ENTRY @@ -196,7 +124,7 @@ async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> No @pytest.mark.parametrize( ("side_effect", "error"), [ - (ERROR_AUTH, {"base": "invalid_access_token"}), + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), (ERROR_UNKNOWN, {"base": "unknown"}), (ERROR_CONNECTION, {"base": "cannot_connect"}), ], diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 8fe92454c36..50a9f2f7733 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -1,8 +1,5 @@ """Test the Tessie sensor platform.""" from datetime import timedelta -from unittest.mock import patch - -import pytest from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL from homeassistant.components.tessie.sensor import TessieStatus @@ -25,15 +22,6 @@ from tests.common import async_fire_time_changed WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) -@pytest.fixture -def mock_get_state(): - """Mock get_state function.""" - with patch( - "homeassistant.components.tessie.coordinator.get_state", - ) as mock_get_state: - yield mock_get_state - - async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: """Tests that the coordinator handles online vehciles.""" From 54c218c139cf36e559a96c9598657618f209af81 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 12 Dec 2023 07:17:09 +0000 Subject: [PATCH 316/927] Updates V2C sensor icons (#105534) update icons --- homeassistant/components/v2c/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 0c860943922..ed642510a34 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -41,6 +41,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_power", translation_key="charge_power", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -49,6 +50,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_energy", translation_key="charge_energy", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, @@ -57,6 +59,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_time", translation_key="charge_time", + icon="mdi:timer", native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.DURATION, @@ -65,6 +68,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="house_power", translation_key="house_power", + icon="mdi:home-lightning-bolt", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -73,6 +77,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="fv_power", translation_key="fv_power", + icon="mdi:solar-power-variant", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -99,7 +104,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): """Defines a base v2c sensor entity.""" entity_description: V2CSensorEntityDescription - _attr_icon = "mdi:ev-station" def __init__( self, From 324aa171c60f64ffa94afb42cbee5f7fd81114f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Dec 2023 08:20:26 +0100 Subject: [PATCH 317/927] Bump sigstore/cosign-installer from 3.2.0 to 3.3.0 (#105537) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index a646510582a..378208fbdf4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -331,7 +331,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.2.0 + uses: sigstore/cosign-installer@v3.3.0 with: cosign-release: "v2.0.2" From f4ee2a1ab41e560ad654949a6f161a43c2c3e2eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Dec 2023 21:24:24 -1000 Subject: [PATCH 318/927] Bump anyio to 4.1.0 (#105529) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ee6c9ba3ea..1fce2f8092b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -108,7 +108,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.0.0 +anyio==4.1.0 h11==0.14.0 httpcore==0.18.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f6835fdbaf1..5356ee8663b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -103,7 +103,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.0.0 +anyio==4.1.0 h11==0.14.0 httpcore==0.18.0 From a66c9bb7b66f9c3327e795b20034156650574195 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Dec 2023 08:28:08 +0100 Subject: [PATCH 319/927] Update stale doc strings in entity platform tests (#105526) --- tests/components/binary_sensor/test_init.py | 4 ++-- tests/components/sensor/test_init.py | 2 +- tests/components/vacuum/test_init.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 437a2e1efa6..074ecb4434a 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -102,7 +102,7 @@ async def test_name(hass: HomeAssistant) -> None: config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( @@ -172,7 +172,7 @@ async def test_entity_category_config_raises_error( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2]) mock_platform( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index fc714a543bf..9164bb442c3 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2424,7 +2424,7 @@ async def test_name(hass: HomeAssistant) -> None: config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 7c5c0de1674..3cf77d4f420 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -89,7 +89,7 @@ async def test_deprecated_base_class( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test vacuum platform via config entry.""" async_add_entities([entity1]) mock_platform( From 319d6db55b7169465bf86361d2dd50bed12b3042 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 12 Dec 2023 08:29:10 +0100 Subject: [PATCH 320/927] Migrate device_sun_light_trigger tests to use freezegun (#105520) --- .../device_sun_light_trigger/test_init.py | 167 +++++++++--------- 1 file changed, 85 insertions(+), 82 deletions(-) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 724ae612f0d..ada1c03a923 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -72,13 +73,15 @@ async def scanner(hass, enable_custom_integrations): return scanner -async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: +async def test_lights_on_when_sun_sets( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner +) -> None: """Test lights go on when there is someone home and the sun sets.""" test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + freezer.move_to(test_time) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) await hass.services.async_call( light.DOMAIN, @@ -88,9 +91,9 @@ async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: ) test_time = test_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() + freezer.move_to(test_time) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == STATE_ON @@ -128,22 +131,22 @@ async def test_lights_turn_off_when_everyone_leaves( async def test_lights_turn_on_when_coming_home_after_sun_set( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) - await hass.async_block_till_done() + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON @@ -152,85 +155,85 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( async def test_lights_turn_on_when_coming_home_after_sun_set_person( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" device_1 = f"{DOMAIN}.device_1" device_2 = f"{DOMAIN}.device_2" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) - hass.states.async_set(device_1, STATE_NOT_HOME) - hass.states.async_set(device_2, STATE_NOT_HOME) - await hass.async_block_till_done() + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) + hass.states.async_set(device_1, STATE_NOT_HOME) + hass.states.async_set(device_2, STATE_NOT_HOME) + await hass.async_block_till_done() - assert all( - not light.is_on(hass, ent_id) - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" + assert all( + not light.is_on(hass, ent_id) + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" - assert await async_setup_component( - hass, - "person", - {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, - ) + assert await async_setup_component( + hass, + "person", + {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, + ) - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "person_me", - created_by_service=False, - entity_ids=["person.me"], - icon=None, - mode=None, - object_id=None, - order=None, - ) + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "person_me", + created_by_service=False, + entity_ids=["person.me"], + icon=None, + mode=None, + object_id=None, + order=None, + ) - assert await async_setup_component( - hass, - device_sun_light_trigger.DOMAIN, - {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, - ) + assert await async_setup_component( + hass, + device_sun_light_trigger.DOMAIN, + {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, + ) - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" + assert hass.states.get("person.me").state == "not_home" - # Unrelated device has no impact - hass.states.async_set(device_2, STATE_HOME) - await hass.async_block_till_done() + # Unrelated device has no impact + hass.states.async_set(device_2, STATE_HOME) + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "not_home" - # person home switches on - hass.states.async_set(device_1, STATE_HOME) - await hass.async_block_till_done() - await hass.async_block_till_done() + # person home switches on + hass.states.async_set(device_1, STATE_HOME) + await hass.async_block_till_done() + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == light.STATE_ON - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "home" + assert all( + hass.states.get(ent_id).state == light.STATE_ON + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "home" async def test_initialize_start(hass: HomeAssistant) -> None: From 4859226496f2c5037fbd99340a8d3b6aa4d89538 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 12 Dec 2023 08:30:08 +0100 Subject: [PATCH 321/927] Migrate geonetnz_* tests to use freezegun (#105521) --- .../geonetnz_quakes/test_geo_location.py | 23 +++++++++++-------- .../geonetnz_volcano/test_sensor.py | 9 +++++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 561d9aaedeb..afc6ada75cd 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED @@ -38,7 +40,11 @@ from tests.common import async_fire_time_changed CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -64,9 +70,8 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) await hass.async_block_till_done() @@ -167,17 +172,17 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> assert len(entity_registry.entities) == 1 -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update, patch( + freezer.move_to(dt_util.utcnow()) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index a237fb2c314..4d11ff0673c 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geonetnz_volcano from homeassistant.components.geo_location import ATTR_DISTANCE @@ -149,15 +150,17 @@ async def test_setup(hass: HomeAssistant) -> None: ) -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 1, 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + freezer.move_to(dt_util.utcnow()) + with patch( "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=AsyncMock ) as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.__init__" From e2abd3b8d0e956ffd7d65c8fe19f8c10e417a794 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Dec 2023 21:31:23 -1000 Subject: [PATCH 322/927] Bump bluetooth libraries (#105522) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 1 + tests/components/private_ble_device/test_sensor.py | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5d54ae6ea82..5f8cdbea939 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.10.0" + "habluetooth==0.11.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1fce2f8092b..5d959667a8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,10 +23,10 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.10.0 +habluetooth==0.11.1 hass-nabucasa==0.74.0 hassil==1.5.1 -home-assistant-bluetooth==1.10.4 +home-assistant-bluetooth==1.11.0 home-assistant-frontend==20231208.2 home-assistant-intents==2023.12.05 httpx==0.25.0 diff --git a/pyproject.toml b/pyproject.toml index 7b1b025ee24..b30e611d4a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.25.0", - "home-assistant-bluetooth==1.10.4", + "home-assistant-bluetooth==1.11.0", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.2.0", diff --git a/requirements.txt b/requirements.txt index 250a0948714..4faf7f8b2c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.25.0 -home-assistant-bluetooth==1.10.4 +home-assistant-bluetooth==1.11.0 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 90b3435979b..b74ee315c32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.10.0 +habluetooth==0.11.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4568fdd26e..d95eb332059 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.10.0 +habluetooth==0.11.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 33683977ef0..ba28d8fa19c 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -897,6 +897,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( """Clear all devices.""" self._discovered_device_advertisement_datas.clear() self._discovered_device_timestamps.clear() + self._previous_service_info.clear() new_info_callback = async_get_advertisement_callback(hass) connector = ( diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index a5175789909..15e205c8c86 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -94,7 +94,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" # MAC address changes, the broadcast interval is kept From ac656847cb521d8cb621379f71f6a5b578bbb91a Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Tue, 12 Dec 2023 07:38:12 +0000 Subject: [PATCH 323/927] Bump pyhiveapi to v0.5.16 (#105513) Co-authored-by: Khole Jones --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 67da3617b44..870223f8fe6 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.14"] + "requirements": ["pyhiveapi==0.5.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index b74ee315c32..b8210059527 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1788,7 +1788,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d95eb332059..0fed6ef512a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1353,7 +1353,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 From 6908497c3dade54900643aac43681d5895998989 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Dec 2023 08:44:35 +0100 Subject: [PATCH 324/927] Add minor version to config entries (#105479) --- homeassistant/config_entries.py | 12 +++- homeassistant/data_entry_flow.py | 3 + tests/common.py | 2 + .../airly/snapshots/test_diagnostics.ambr | 1 + .../airnow/snapshots/test_diagnostics.ambr | 1 + .../airvisual/snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../airzone/snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../axis/snapshots/test_diagnostics.ambr | 1 + .../blink/snapshots/test_diagnostics.ambr | 1 + tests/components/cloud/test_repairs.py | 1 + .../co2signal/snapshots/test_diagnostics.ambr | 1 + .../coinbase/snapshots/test_diagnostics.ambr | 1 + .../components/config/test_config_entries.py | 3 + .../deconz/snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../elgato/snapshots/test_config_flow.ambr | 6 ++ .../snapshots/test_config_flow.ambr | 2 + .../snapshots/test_diagnostics.ambr | 1 + .../esphome/snapshots/test_diagnostics.ambr | 1 + .../forecast_solar/snapshots/test_init.ambr | 1 + .../snapshots/test_config_flow.ambr | 4 ++ .../gios/snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_diagnostics.ambr | 1 + tests/components/guardian/test_diagnostics.py | 1 + tests/components/hassio/test_repairs.py | 6 ++ .../snapshots/test_config_flow.ambr | 8 +++ tests/components/hue/test_services.py | 44 +++++++------ .../iqvia/snapshots/test_diagnostics.ambr | 1 + tests/components/kitchen_sink/test_init.py | 1 + .../kostal_plenticore/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../netatmo/snapshots/test_diagnostics.ambr | 1 + .../nextdns/snapshots/test_diagnostics.ambr | 1 + tests/components/notion/test_diagnostics.py | 1 + .../onvif/snapshots/test_diagnostics.ambr | 1 + tests/components/openuv/test_diagnostics.py | 1 + .../components/philips_js/test_config_flow.py | 1 + .../pi_hole/snapshots/test_diagnostics.ambr | 1 + tests/components/ps4/test_init.py | 1 + .../components/purpleair/test_diagnostics.py | 1 + .../rainmachine/test_diagnostics.py | 2 + .../recollect_waste/test_diagnostics.py | 1 + .../components/repairs/test_websocket_api.py | 1 + .../ridwell/snapshots/test_diagnostics.ambr | 1 + .../components/samsungtv/test_diagnostics.py | 3 + .../snapshots/test_diagnostics.ambr | 1 + .../components/simplisafe/test_diagnostics.py | 1 + tests/components/subaru/test_config_flow.py | 2 + .../switcher_kis/test_diagnostics.py | 1 + .../snapshots/test_config_flow.ambr | 4 ++ .../twinkly/snapshots/test_diagnostics.ambr | 1 + tests/components/unifi/test_device_tracker.py | 1 + tests/components/unifi/test_diagnostics.py | 1 + tests/components/unifi/test_switch.py | 1 + .../uptime/snapshots/test_config_flow.ambr | 2 + .../vicare/snapshots/test_diagnostics.ambr | 1 + .../watttime/snapshots/test_diagnostics.ambr | 1 + tests/components/webostv/test_diagnostics.py | 1 + .../whois/snapshots/test_config_flow.ambr | 10 +++ .../wyoming/snapshots/test_config_flow.ambr | 6 ++ tests/snapshots/test_config_entries.ambr | 1 + tests/test_bootstrap.py | 1 + tests/test_config_entries.py | 61 ++++++++++++++++--- 67 files changed, 198 insertions(+), 31 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 756b2def581..336261c3632 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -205,6 +205,7 @@ class ConfigEntry: __slots__ = ( "entry_id", "version", + "minor_version", "domain", "title", "data", @@ -233,7 +234,9 @@ class ConfigEntry: def __init__( self, + *, version: int, + minor_version: int, domain: str, title: str, data: Mapping[str, Any], @@ -252,6 +255,7 @@ class ConfigEntry: # Version of the configuration. self.version = version + self.minor_version = minor_version # Domain the configuration belongs to self.domain = domain @@ -631,7 +635,8 @@ class ConfigEntry: while isinstance(handler, functools.partial): handler = handler.func # type: ignore[unreachable] - if self.version == handler.VERSION: + same_major_version = self.version == handler.VERSION + if same_major_version and self.minor_version == handler.MINOR_VERSION: return True if not (integration := self._integration_for_domain): @@ -639,6 +644,8 @@ class ConfigEntry: component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: + if same_major_version: + return True _LOGGER.error( "Migration handler not found for entry %s for %s", self.title, @@ -676,6 +683,7 @@ class ConfigEntry: return { "entry_id": self.entry_id, "version": self.version, + "minor_version": self.minor_version, "domain": self.domain, "title": self.title, "data": dict(self.data), @@ -974,6 +982,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): entry = ConfigEntry( version=result["version"], + minor_version=result["minor_version"], domain=result["handler"], title=result["title"], data=result["data"], @@ -1196,6 +1205,7 @@ class ConfigEntries: config_entry = ConfigEntry( version=entry["version"], + minor_version=entry.get("minor_version", 1), domain=domain, entry_id=entry_id, data=entry["data"], diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0ea195a3ff..b02fcbfcd1f 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -94,6 +94,7 @@ class FlowResult(TypedDict, total=False): handler: Required[str] last_step: bool | None menu_options: list[str] | dict[str, str] + minor_version: int options: Mapping[str, Any] preview: str | None progress_action: str @@ -470,6 +471,7 @@ class FlowHandler: # Set by developer VERSION = 1 + MINOR_VERSION = 1 @property def source(self) -> str | None: @@ -549,6 +551,7 @@ class FlowHandler: """Finish flow.""" flow_result = FlowResult( version=self.VERSION, + minor_version=self.MINOR_VERSION, type=FlowResultType.CREATE_ENTRY, flow_id=self.flow_id, handler=self.handler, diff --git a/tests/common.py b/tests/common.py index 15498019b16..1d0b278a6cb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -890,6 +890,7 @@ class MockConfigEntry(config_entries.ConfigEntry): domain="test", data=None, version=1, + minor_version=1, entry_id=None, source=config_entries.SOURCE_USER, title="Mock Title", @@ -910,6 +911,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "pref_disable_polling": pref_disable_polling, "options": options, "version": version, + "minor_version": minor_version, "title": title, "unique_id": unique_id, "disabled_by": disabled_by, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index a224ea07d46..c22e96a2082 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -11,6 +11,7 @@ 'disabled_by': None, 'domain': 'airly', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 80c6de427ca..71fda040c1d 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'disabled_by': None, 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'radius': 150, }), diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index c805c5f9cb7..cb9d25b8790 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ 'disabled_by': None, 'domain': 'airvisual', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'show_on_map': True, }), diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index 96cda8e012f..be709621e31 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airvisual_pro', 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 9cb6e550711..8a8573689fa 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -240,6 +240,7 @@ 'disabled_by': None, 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 594a5e6765a..4a7217a08c5 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airzone_cloud', 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 4b231660c4b..b4aede7948c 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'ambient_station', 'entry_id': '382cf7643f016fd48b3fe52163fe8877', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 74a1f110c14..9960fc9bfd2 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'disabled_by': None, 'domain': 'axis', 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', + 'minor_version': 1, 'options': dict({ 'events': True, }), diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index 7fb13c97548..a1c18223c11 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ }), 'disabled_by': None, 'domain': 'blink', + 'minor_version': 1, 'options': dict({ 'scan_interval': 300, }), diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index f83de408bcc..8d890a503e1 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -154,6 +154,7 @@ async def test_legacy_subscription_repair_flow( "handler": DOMAIN, "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue( diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 53a0f000f28..645e0bd87e9 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'co2signal', 'entry_id': '904a74160aa6f335526706bee85dfb83', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 38224a9992f..9079a7682c8 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -47,6 +47,7 @@ 'disabled_by': None, 'domain': 'coinbase', 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', + 'minor_version': 1, 'options': dict({ 'account_balance_currencies': list([ ]), diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bfee7551cff..414f4eb39f2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -532,6 +532,7 @@ async def test_create_account( "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -609,6 +610,7 @@ async def test_two_step_flow( "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -942,6 +944,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: "version": 1, "description": None, "description_placeholders": None, + "minor_version": 1, } diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index bbd96f1751c..911f2e134f2 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'deconz', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'master': True, }), diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index d2ff64ad596..8c069de8f62 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -40,6 +40,7 @@ 'disabled_by': None, 'domain': 'devolo_home_control', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 236588b87ad..317aaac0116 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -24,6 +24,7 @@ 'disabled_by': None, 'domain': 'devolo_home_network', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index 46180994e61..39202d383fa 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -14,6 +14,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -25,6 +26,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -55,6 +57,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -66,6 +69,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -95,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -106,6 +111,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 68c46a705d7..9b4b3bfc635 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -11,6 +11,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'energyzero', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -19,6 +20,7 @@ 'disabled_by': None, 'domain': 'energyzero', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 098fc4ee37e..f0021e1934a 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'disabled_by': None, 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index d8de8f06bc6..0d2f0e60b82 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'esphome', 'entry_id': '08d821dc059cf4f645cb024d32c8e708', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index a009105e2e6..43145bcef9e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'disabled_by': None, 'domain': 'forecast_solar', 'entry_id': , + 'minor_version': 1, 'options': dict({ 'api_key': 'abcdef12345', 'azimuth': 190, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 31925e2d626..a2fe4b63cf8 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -31,6 +31,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -40,6 +41,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -238,6 +240,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -247,6 +250,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 67691602fcf..1401b1e22a0 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'gios', 'entry_id': '86129426118ae32020417a53712d6eef', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index dffcddf5de5..663979eda77 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -7,6 +7,7 @@ }), 'disabled_by': None, 'domain': 'google_assistant', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index b58b2ccdba3..ec288461661 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -23,6 +23,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "guardian", "title": REDACTED, "data": { diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 21bf7e5b47a..5dd73a21615 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -100,6 +100,7 @@ async def test_supervisor_issue_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -195,6 +196,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -309,6 +311,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -389,6 +392,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -488,6 +492,7 @@ async def test_mount_failed_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -599,6 +604,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index b5b7411532e..663d9153991 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -52,6 +54,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -61,6 +64,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -92,6 +96,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -101,6 +106,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -128,6 +134,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -137,6 +144,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py index 01b349c7361..ec1c1154d75 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -49,11 +49,12 @@ SCENE_RESPONSE = { async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -85,11 +86,12 @@ async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: async def test_hue_activate_scene_transition(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene with transition.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -123,11 +125,12 @@ async def test_hue_activate_scene_group_not_found( ) -> None: """Test failed hue_activate_scene due to missing group.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -156,11 +159,12 @@ async def test_hue_activate_scene_scene_not_found( ) -> None: """Test failed hue_activate_scene due to missing scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index 49006716fb3..c46a2cc15e3 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -350,6 +350,7 @@ 'disabled_by': None, 'domain': 'iqvia', 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index ebd0f781d22..71f3a83c701 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -229,6 +229,7 @@ async def test_issues_created( "description_placeholders": None, "flow_id": flow_id, "handler": DOMAIN, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 87c8c0e26a8..d509a323e6a 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -43,6 +43,7 @@ async def test_entry_diagnostics( "config_entry": { "entry_id": "2ab8dd92a62787ddfe213a67e09406bd", "version": 1, + "minor_version": 1, "domain": "kostal_plenticore", "title": "scb", "data": {"host": "192.168.1.2", "password": REDACTED}, diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 30094f97cd3..9d880746ff9 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -17,6 +17,7 @@ 'disabled_by': None, 'domain': 'lacrosse_view', 'entry_id': 'lacrosse_view_test_entry_id', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index bd9005bd389..cd547481de9 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -588,6 +588,7 @@ }), 'disabled_by': None, 'domain': 'netatmo', + 'minor_version': 1, 'options': dict({ 'weather_areas': dict({ 'Home avg': dict({ diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 071d14f183b..5040c6e052e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'nextdns', 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 14a1a0e1768..07a67cb1429 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -18,6 +18,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": DOMAIN, "title": REDACTED, "data": {"username": REDACTED, "password": REDACTED}, diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index e10c8791ba9..c4f692a4e61 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -13,6 +13,7 @@ 'disabled_by': None, 'domain': 'onvif', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'enable_webhooks': True, 'extra_arguments': '-pred 1', diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index fa7c7898037..e7efc459630 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "openuv", "title": REDACTED, "data": { diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 603e278d592..8229f4e8fa9 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -160,6 +160,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "data": MOCK_CONFIG_PAIRED, "version": 1, "options": {}, + "minor_version": 1, } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 69c77acc64a..865494b5e9f 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -25,6 +25,7 @@ 'disabled_by': None, 'domain': 'pi_hole', 'entry_id': 'pi_hole_mock_entry', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 3b8dc5e1e24..1252348b3e0 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -44,6 +44,7 @@ MOCK_DATA = {CONF_TOKEN: MOCK_CREDS, "devices": [MOCK_DEVICE]} MOCK_FLOW_RESULT = { "version": VERSION, + "minor_version": 1, "handler": DOMAIN, "type": data_entry_flow.FlowResultType.CREATE_ENTRY, "title": "test_ps4", diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 35dc515241c..85b078d0765 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "purpleair", "title": REDACTED, "data": { diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 9c3aa6cd7de..47cb3202026 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { @@ -645,6 +646,7 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index e905f4a5606..69ff1596d7c 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "recollect_waste", "title": REDACTED, "data": {"place_id": REDACTED, "service_id": TEST_SERVICE_ID}, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 6c9b51a7cf6..1f68c9a28d3 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -338,6 +338,7 @@ async def test_fix_issue( "description_placeholders": None, "flow_id": flow_id, "handler": domain, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index a98374d2941..d32b1d3f446 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -36,6 +36,7 @@ 'disabled_by': None, 'domain': 'ridwell', 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 231880a009b..651b6f27a44 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -41,6 +41,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -77,6 +78,7 @@ async def test_entry_diagnostics_encrypted( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -112,6 +114,7 @@ async def test_entry_diagnostics_encrypte_offline( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 05320c147e5..0efd10fb914 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'screenlogic', 'entry_id': 'screenlogictest', + 'minor_version': 1, 'options': dict({ 'scan_interval': 30, }), diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 3153674ce57..538165bd769 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "simplisafe", "title": REDACTED, "data": {"token": REDACTED, "username": REDACTED}, diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index c3df10ed618..7e892d2c99a 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -130,6 +130,7 @@ async def test_user_form_pin_not_required( "version": 1, "data": deepcopy(TEST_CONFIG), "options": {}, + "minor_version": 1, } expected["data"][CONF_PIN] = None @@ -316,6 +317,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "version": 1, "data": TEST_CONFIG, "options": {}, + "minor_version": 1, } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 0af89cd238c..f238bceb39e 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -48,6 +48,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "switcher_kis", "title": "Mock Title", "data": {}, diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index 7acb466d997..00b96062052 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -15,6 +15,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -27,6 +28,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -57,6 +59,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -69,6 +72,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 7a7dc2557ef..2a10154c3da 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -30,6 +30,7 @@ 'disabled_by': None, 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 5a12b99d10b..34d43129a94 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -930,6 +930,7 @@ async def test_restoring_client( config_entry = config_entries.ConfigEntry( version=1, + minor_version=1, domain=UNIFI_DOMAIN, title="Mock Title", data=ENTRY_CONFIG, diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 638e79ae649..127b9b79c2b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -129,6 +129,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "unifi", "entry_id": "1", + "minor_version": 1, "options": { "allow_bandwidth_sensors": True, "allow_uptime_sensors": True, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 00ebcd0e683..6a9e58b6f76 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1628,6 +1628,7 @@ async def test_updating_unique_id( config_entry = config_entries.ConfigEntry( version=1, + minor_version=1, domain=UNIFI_DOMAIN, title="Mock Title", data=ENTRY_CONFIG, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index ac4b7396839..3e5b492f871 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -10,6 +10,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'uptime', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -18,6 +19,7 @@ 'disabled_by': None, 'domain': 'uptime', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index dc1b217948f..dfc29d46cc2 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4705,6 +4705,7 @@ 'disabled_by': None, 'domain': 'vicare', 'entry_id': '1234', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index e1cf4a8a42f..2ed35c19ad1 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -19,6 +19,7 @@ }), 'disabled_by': None, 'domain': 'watttime', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index f0f411fb278..b7d1646c6b6 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -44,6 +44,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "webostv", "title": "fake_webos", "data": { diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 6eec94d42a5..08f3861dcd2 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -48,6 +50,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -57,6 +60,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -84,6 +88,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -93,6 +98,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -120,6 +126,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -129,6 +136,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -156,6 +164,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -165,6 +174,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index 99f411027f5..a0e0c7c5011 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -55,6 +55,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -65,6 +66,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -97,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -107,6 +110,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -139,6 +143,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -149,6 +154,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index beaa60cf762..bfb583ba8db 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -6,6 +6,7 @@ 'disabled_by': None, 'domain': 'test', 'entry_id': 'mock-entry', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 42d679d7ce6..4c350168d4e 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -913,6 +913,7 @@ async def test_bootstrap_dependencies( """Mock the MQTT config flow.""" VERSION = 1 + MINOR_VERSION = 1 entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) entry.add_to_hass(hass) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 40e3b3b4c3c..e9989b6839e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -134,11 +134,15 @@ async def test_call_setup_entry_without_reload_support(hass: HomeAssistant) -> N assert not entry.supports_unload -async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test we call .async_migrate_entry when version mismatch.""" entry = MockConfigEntry(domain="comp") assert not entry.supports_unload - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) mock_migrate_entry = AsyncMock(return_value=True) @@ -164,10 +168,14 @@ async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: assert entry.supports_unload -async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_false( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if returns false.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -192,10 +200,14 @@ async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> No assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_exception( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if exception raised.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -220,10 +232,14 @@ async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) - assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_not_bool( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if boolean not returned.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -248,12 +264,14 @@ async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (2, 2)]) async def test_call_async_migrate_entry_failure_not_supported( - hass: HomeAssistant, + hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if async_migrate_entry not implemented.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -269,6 +287,29 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(1, 2)]) +async def test_call_async_migrate_entry_not_supported_minor_version( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: + """Test migration without async_migrate_entry and minor version changed.""" + entry = MockConfigEntry(domain="comp") + entry.version = major_version + entry.minor_version = minor_version + entry.add_to_hass(hass) + assert not entry.supports_unload + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + + result = await async_setup_component(hass, "comp", {}) + assert result + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert not entry.supports_unload + + async def test_remove_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 9d44dc4437e40f8615f58b5d0e226599a721fc73 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 12 Dec 2023 10:41:00 +0100 Subject: [PATCH 325/927] Add Fast.com Device Info (#105528) Co-authored-by: G Johansson --- homeassistant/components/fastdotcom/sensor.py | 9 ++++++++- homeassistant/components/fastdotcom/strings.json | 7 +++++++ tests/components/fastdotcom/test_coordinator.py | 6 +++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b82b20defb5..2ca0b2d9168 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate 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 @@ -31,12 +32,13 @@ class SpeedtestSensor( ): """Implementation of a Fast.com sensor.""" - _attr_name = "Fast.com Download" + _attr_translation_key = "download" _attr_device_class = SensorDeviceClass.DATA_RATE _attr_native_unit_of_measurement = UnitOfDataRate.MEGABITS_PER_SECOND _attr_state_class = SensorStateClass.MEASUREMENT _attr_icon = "mdi:speedometer" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, entry_id: str, coordinator: FastdotcomDataUpdateCoordindator @@ -44,6 +46,11 @@ class SpeedtestSensor( """Initialize the sensor.""" super().__init__(coordinator) self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + configuration_url="https://www.fast.com", + ) @property def native_value( diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index d647250b423..d274ca8d679 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -9,6 +9,13 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "entity": { + "sensor": { + "download": { + "name": "Download" + } + } + }, "services": { "speedtest": { "name": "Speed test", diff --git a/tests/components/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py index 254301950fb..5ee8c76092b 100644 --- a/tests/components/fastdotcom/test_coordinator.py +++ b/tests/components/fastdotcom/test_coordinator.py @@ -28,7 +28,7 @@ async def test_fastdotcom_data_update_coordinator( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.fast_com_download") + state = hass.states.get("sensor.mock_title_download") assert state is not None assert state.state == "5.0" @@ -39,7 +39,7 @@ async def test_fastdotcom_data_update_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.fast_com_download") + state = hass.states.get("sensor.mock_title_download") assert state.state == "10.0" with patch( @@ -50,5 +50,5 @@ async def test_fastdotcom_data_update_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.fast_com_download") + state = hass.states.get("sensor.mock_title_download") assert state.state is STATE_UNAVAILABLE From fb615817b4ccb3a0dcfeaf0384a1ff29633170cd Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 12 Dec 2023 10:55:22 +0100 Subject: [PATCH 326/927] Add Tado error handling to fetching devices (#105546) --- homeassistant/components/tado/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 1cd21634c8e..c9ed4b34c30 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -181,7 +181,12 @@ class TadoConnector: def update_devices(self): """Update the device data from Tado.""" - devices = self.tado.getDevices() + try: + devices = self.tado.getDevices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating devices") + return + for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) From 2631fde0f7347d99e88d878ea428833897628565 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 12 Dec 2023 14:40:38 +0100 Subject: [PATCH 327/927] Patch aiohttp server app router freeze in tests (#105555) * Add test for registering a http view late * Patch aiohttp server app router freeze * Correct language --- tests/conftest.py | 2 ++ tests/test_test_fixtures.py | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 777b2073847..1e70ad48065 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -487,6 +487,8 @@ def aiohttp_client( if isinstance(__param, Application): server_kwargs = server_kwargs or {} server = TestServer(__param, loop=loop, **server_kwargs) + # Registering a view after starting the server should still work. + server.app._router.freeze = lambda: None client = CoalescingClient(server, loop=loop, **kwargs) elif isinstance(__param, BaseTestServer): client = TestClient(__param, loop=loop, **kwargs) diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 1c0fe0a7eaa..eb2103d4272 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,10 +1,16 @@ """Test test fixture configuration.""" +from http import HTTPStatus import socket +from aiohttp import web import pytest import pytest_socket +from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.setup import async_setup_component + +from .typing import ClientSessionGenerator def test_sockets_disabled() -> None: @@ -27,3 +33,38 @@ async def test_hass_cv(hass: HomeAssistant) -> None: in the fixture and that async_get_hass() works correctly. """ assert async_get_hass() is hass + + +def register_view(hass: HomeAssistant) -> None: + """Register a view.""" + + class TestView(HomeAssistantView): + """Test view to serve the test.""" + + requires_auth = False + url = "/api/test" + name = "api:test" + + async def get(self, request: web.Request) -> web.Response: + """Return a test result.""" + return self.json({"test": True}) + + hass.http.register_view(TestView()) + + +async def test_aiohttp_client_frozen_router_view( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test aiohttp_client fixture patches frozen router for views.""" + assert await async_setup_component(hass, "http", {}) + await hass.async_block_till_done() + + # Registering the view after starting the server should still work. + client = await hass_client() + register_view(hass) + + response = await client.get("/api/test") + assert response.status == HTTPStatus.OK + result = await response.json() + assert result["test"] is True From 5c514b6b1944cc52f795fd759880a491cbdf3263 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 14:44:17 +0100 Subject: [PATCH 328/927] Add Suez Water to strict typing (#105559) --- .strict-typing | 1 + homeassistant/components/suez_water/sensor.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 6180379f977..ee39824f476 100644 --- a/.strict-typing +++ b/.strict-typing @@ -318,6 +318,7 @@ homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* homeassistant.components.streamlabswater.* +homeassistant.components.suez_water.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index fc5b804137d..7d7540ed1c0 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -74,7 +74,7 @@ class SuezSensor(SensorEntity): self.client = client self._attr_extra_state_attributes = {} - def _fetch_data(self): + def _fetch_data(self) -> None: """Fetch latest data from Suez.""" try: self.client.update() diff --git a/mypy.ini b/mypy.ini index 2a3a5f0fb0f..6e67167daca 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2942,6 +2942,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.suez_water.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true From 280637822b21d633a7c3e80b3427f6a78d4180e4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Dec 2023 15:49:01 +0100 Subject: [PATCH 329/927] Use mocked entity platform for lock service tests (#105020) * Use mocked entity platform for lock service tests * Cleanup old mock class * Follow up on code review * Improve mock entity platform * Use entity_id of passed entity instead of constant --- tests/components/lock/conftest.py | 141 ++++++++ tests/components/lock/test_init.py | 498 +++++++++++++++-------------- 2 files changed, 403 insertions(+), 236 deletions(-) create mode 100644 tests/components/lock/conftest.py diff --git a/tests/components/lock/conftest.py b/tests/components/lock/conftest.py new file mode 100644 index 00000000000..07399a39e92 --- /dev/null +++ b/tests/components/lock/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for the lock entity platform tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + LockEntity, + LockEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockLock(LockEntity): + """Mocked lock entity.""" + + def __init__( + self, + supported_features: LockEntityFeature = LockEntityFeature(0), + code_format: str | None = None, + ) -> None: + """Initialize the lock.""" + self.calls_open = MagicMock() + self.calls_lock = MagicMock() + self.calls_unlock = MagicMock() + self._attr_code_format = code_format + self._attr_supported_features = supported_features + self._attr_has_entity_name = True + self._attr_name = "test_lock" + self._attr_unique_id = "very_unique_lock_id" + super().__init__() + + def lock(self, **kwargs: Any) -> None: + """Mock lock lock calls.""" + self.calls_lock(**kwargs) + + def unlock(self, **kwargs: Any) -> None: + """Mock lock unlock calls.""" + self.calls_unlock(**kwargs) + + def open(self, **kwargs: Any) -> None: + """Mock lock open calls.""" + self.calls_open(**kwargs) + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture +async def code_format() -> str | None: + """Return the code format for the test lock entity.""" + return None + + +@pytest.fixture(name="supported_features") +async def lock_supported_features() -> LockEntityFeature: + """Return the supported features for the test lock entity.""" + return LockEntityFeature.OPEN + + +@pytest.fixture(name="mock_lock_entity") +async def setup_lock_platform_test_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + code_format: str | None, + supported_features: LockEntityFeature, +) -> MagicMock: + """Set up lock entity using an entity platform.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, LOCK_DOMAIN) + return True + + MockPlatform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity = MockLock( + supported_features=supported_features, + code_format=code_format, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test lock platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{LOCK_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + + return entity diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 637acc22d05..d8589ea265e 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -2,328 +2,354 @@ from __future__ import annotations from typing import Any -from unittest.mock import MagicMock import pytest from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, + DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, STATE_UNLOCKED, STATE_UNLOCKING, - LockEntity, LockEntityFeature, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from tests.testing_config.custom_components.test.lock import MockLock +from .conftest import MockLock -class MockLockEntity(LockEntity): - """Mock lock to use in tests.""" +async def help_test_async_lock_service( + hass: HomeAssistant, + entity_id: str, + service: str, + code: str | None | UndefinedType = UNDEFINED, +) -> None: + """Help to lock a test lock.""" + data: dict[str, Any] = {"entity_id": entity_id} + if code is not UNDEFINED: + data[ATTR_CODE] = code - def __init__( - self, - code_format: str | None = None, - lock_option_default_code: str = "", - supported_features: LockEntityFeature = LockEntityFeature(0), - ) -> None: - """Initialize mock lock entity.""" - self._attr_supported_features = supported_features - self.calls_lock = MagicMock() - self.calls_unlock = MagicMock() - self.calls_open = MagicMock() - if code_format is not None: - self._attr_code_format = code_format - self._lock_option_default_code = lock_option_default_code - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the lock.""" - self.calls_lock(kwargs) - self._attr_is_locking = False - self._attr_is_locked = True - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the lock.""" - self.calls_unlock(kwargs) - self._attr_is_unlocking = False - self._attr_is_locked = False - - async def async_open(self, **kwargs: Any) -> None: - """Open the door latch.""" - self.calls_open(kwargs) + await hass.services.async_call(DOMAIN, service, data, blocking=True) -async def test_lock_default(hass: HomeAssistant) -> None: +async def test_lock_default(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity with defaults.""" - lock = MockLockEntity() - lock.hass = hass - assert lock.code_format is None - assert lock.state is None + assert mock_lock_entity.code_format is None + assert mock_lock_entity.state is None + assert mock_lock_entity.is_jammed is None + assert mock_lock_entity.is_locked is None + assert mock_lock_entity.is_locking is None + assert mock_lock_entity.is_unlocking is None -async def test_lock_states(hass: HomeAssistant) -> None: +async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity states.""" - lock = MockLockEntity() - lock.hass = hass + assert mock_lock_entity.state is None - assert lock.state is None + mock_lock_entity._attr_is_locking = True + assert mock_lock_entity.is_locking + assert mock_lock_entity.state == STATE_LOCKING - lock._attr_is_locking = True - assert lock.is_locking - assert lock.state == STATE_LOCKING + mock_lock_entity._attr_is_locked = True + mock_lock_entity._attr_is_locking = False + assert mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_LOCKED - await lock.async_handle_lock_service() - assert lock.is_locked - assert lock.state == STATE_LOCKED + mock_lock_entity._attr_is_unlocking = True + assert mock_lock_entity.is_unlocking + assert mock_lock_entity.state == STATE_UNLOCKING - lock._attr_is_unlocking = True - assert lock.is_unlocking - assert lock.state == STATE_UNLOCKING + mock_lock_entity._attr_is_locked = False + mock_lock_entity._attr_is_unlocking = False + assert not mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_UNLOCKED - await lock.async_handle_unlock_service() - assert not lock.is_locked - assert lock.state == STATE_UNLOCKED - - lock._attr_is_jammed = True - assert lock.is_jammed - assert lock.state == STATE_JAMMED - assert not lock.is_locked + mock_lock_entity._attr_is_jammed = True + assert mock_lock_entity.is_jammed + assert mock_lock_entity.state == STATE_JAMMED + assert not mock_lock_entity.is_locked -async def test_set_default_code_option( +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_set_mock_lock_options( hass: HomeAssistant, entity_registry: er.EntityRegistry, - enable_custom_integrations: None, + mock_lock_entity: MockLock, ) -> None: - """Test default code stored in the registry.""" - - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - await hass.async_block_till_done() - - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) - await hass.async_block_till_done() - - entity0: MockLock = platform.ENTITIES["lock1"] + """Test mock attributes and default code stored in the registry.""" entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} ) await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" + assert mock_lock_entity._lock_option_default_code == "1234" + state = hass.states.get(mock_lock_entity.entity_id) + assert state is not None + assert state.attributes["code_format"] == r"^\d{4}$" + assert state.attributes["supported_features"] == LockEntityFeature.OPEN +@pytest.mark.parametrize("code_format", [r"^\d{4}$"]) async def test_default_code_option_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, - enable_custom_integrations: None, + mock_lock_entity: MockLock, ) -> None: """Test default code stored in the registry is updated.""" - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - await hass.async_block_till_done() - - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) - - # Pre-register entities - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - entity_registry.async_update_entity_options( - entry.entity_id, - "lock", - { - "default_code": "5432", - }, - ) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) - await hass.async_block_till_done() - - entity0: MockLock = platform.ENTITIES["lock1"] - assert entity0._lock_option_default_code == "5432" + assert mock_lock_entity._lock_option_default_code == "" entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "4321"} ) await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" + assert mock_lock_entity._lock_option_default_code == "4321" -async def test_lock_open_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_open_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test lock entity with open service.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", supported_features=LockEntityFeature.OPEN + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" + + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" ) - lock.hass = hass - - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - - with pytest.raises(ValueError): - await lock.async_handle_open_service() - with pytest.raises(ValueError): - await lock.async_handle_open_service(code="") - with pytest.raises(ValueError): - await lock.async_handle_open_service(code="HELLO") - await lock.async_handle_open_service(code="1234") - assert lock.calls_open.call_count == 1 + assert mock_lock_entity.calls_open.call_count == 1 + mock_lock_entity.calls_open.assert_called_with(code="1234") -async def test_lock_lock_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_lock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test lock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" - await lock.async_handle_unlock_service(code="1234") - assert not lock.is_locked + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + assert mock_lock_entity.calls_lock.call_count == 0 with pytest.raises(ValueError): - await lock.async_handle_lock_service() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) with pytest.raises(ValueError): - await lock.async_handle_lock_service(code="") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) with pytest.raises(ValueError): - await lock.async_handle_lock_service(code="HELLO") - await lock.async_handle_lock_service(code="1234") - assert lock.is_locked + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" + ) + assert mock_lock_entity.calls_lock.call_count == 1 + mock_lock_entity.calls_lock.assert_called_with(code="1234") -async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_unlock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test unlock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" - await lock.async_handle_lock_service(code="1234") - assert lock.is_locked - - with pytest.raises(ValueError): - await lock.async_handle_unlock_service() - with pytest.raises(ValueError): - await lock.async_handle_unlock_service(code="") - with pytest.raises(ValueError): - await lock.async_handle_unlock_service(code="HELLO") - await lock.async_handle_unlock_service(code="1234") - assert not lock.is_locked - - -async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" ) - lock.hass = hass + mock_lock_entity.calls_lock.assert_called_with(code="1234") + assert mock_lock_entity.calls_unlock.call_count == 0 with pytest.raises(ValueError): - await lock.async_handle_open_service(code="123456") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK + ) with pytest.raises(ValueError): - await lock.async_handle_lock_service(code="123456") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) with pytest.raises(ValueError): - await lock.async_handle_unlock_service(code="123456") - - -async def test_lock_with_no_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - supported_features=LockEntityFeature.OPEN, + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" ) - lock.hass = hass - - await lock.async_handle_open_service() - lock.calls_open.assert_called_with({}) - await lock.async_handle_lock_service() - lock.calls_lock.assert_called_with({}) - await lock.async_handle_unlock_service() - lock.calls_unlock.assert_called_with({}) - - await lock.async_handle_open_service(code="") - lock.calls_open.assert_called_with({}) - await lock.async_handle_lock_service(code="") - lock.calls_lock.assert_called_with({}) - await lock.async_handle_unlock_service(code="") - lock.calls_unlock.assert_called_with({}) + assert mock_lock_entity.calls_unlock.call_count == 1 + mock_lock_entity.calls_unlock.assert_called_with(code="1234") -async def test_lock_with_default_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity with default code that does not match the code format.""" + + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="123456" + ) + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="123456" + ) + with pytest.raises(ValueError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="123456" + ) + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(None, LockEntityFeature.OPEN)], +) +async def test_lock_with_no_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity without code.""" + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_OPEN) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_LOCK) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_UNLOCK) + mock_lock_entity.calls_unlock.assert_called_with() + + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with() + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: """Test lock entity with default code.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="1234", + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} ) - lock.hass = hass + await hass.async_block_till_done() - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - assert lock._lock_option_default_code == "1234" + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "1234" - await lock.async_handle_open_service() - lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await lock.async_handle_lock_service() - lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await lock.async_handle_unlock_service() - lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - - await lock.async_handle_open_service(code="") - lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await lock.async_handle_lock_service(code="") - lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await lock.async_handle_unlock_service(code="") - lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - - -async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: - """Test lock entity with provided code when default code is set.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="1234", + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" ) - lock.hass = hass - - await lock.async_handle_open_service(code="4321") - lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await lock.async_handle_lock_service(code="4321") - lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await lock.async_handle_unlock_service(code="4321") - lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) - - -async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="123456", + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" ) - lock.hass = hass + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - assert lock._lock_option_default_code == "123456" + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: + """Test lock entity with illegal default code.""" + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "123456"} + ) + await hass.async_block_till_done() + + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "" with pytest.raises(ValueError): - await lock.async_handle_open_service() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) with pytest.raises(ValueError): - await lock.async_handle_lock_service() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) with pytest.raises(ValueError): - await lock.async_handle_unlock_service() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK + ) From 64c3cfca17c8f94af3072356c9178ed478c2f6fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 17:27:41 +0100 Subject: [PATCH 330/927] Add Airvisual pro to strict typing (#105568) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index ee39824f476..ab4cc944ea1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -48,6 +48,7 @@ homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airvisual.* +homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* diff --git a/mypy.ini b/mypy.ini index 6e67167daca..3e882d15812 100644 --- a/mypy.ini +++ b/mypy.ini @@ -240,6 +240,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airvisual_pro.*] +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.airzone.*] check_untyped_defs = true disallow_incomplete_defs = true From ec1cde77f635361a84de0ae70233adc74269423e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 07:54:33 -1000 Subject: [PATCH 331/927] Add support for Happy Eyeballs to homekit_controller (#105454) --- homeassistant/components/homekit_controller/config_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 088747d39ff..08444555aca 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -257,6 +257,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) updated_ip_port = { "AccessoryIP": discovery_info.host, + "AccessoryIPs": [ + str(ip_addr) + for ip_addr in discovery_info.ip_addresses + if not ip_addr.is_link_local and not ip_addr.is_unspecified + ], "AccessoryPort": discovery_info.port, } # If the device is already paired and known to us we should monitor c# From c7a95d565417fa0e031657575fcc60d1a32d6541 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 07:55:12 -1000 Subject: [PATCH 332/927] Bump dbus-fast to 2.21.0 (#105536) --- 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 5f8cdbea939..5abec24b6d1 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", - "dbus-fast==2.20.0", + "dbus-fast==2.21.0", "habluetooth==0.11.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d959667a8d..53ed955f791 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.20.0 +dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index b8210059527..b7ae74413fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.20.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fed6ef512a..af90b1d3caf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,7 +539,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.20.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 From f58af0d71780d7de75443968a93cf8f697319f7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 08:07:20 -1000 Subject: [PATCH 333/927] Bump aiohomekit to 3.1.0 (#105584) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 91fd199e17c..e6ef6d58df6 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.9"], + "requirements": ["aiohomekit==3.1.0"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b7ae74413fe..5022fd8e7a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.0 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af90b1d3caf..41ec51327dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.0 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From 54d314d1d03b2a8652b87fdc54b450daffcfcacf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 08:41:50 -1000 Subject: [PATCH 334/927] Bump aioesphomeapi to 20.0.0 (#105586) changelog: https://github.com/esphome/aioesphomeapi/compare/v19.3.1...v20.0.0 - Add happy eyeballs support (RFC 8305) (#789) Note that nothing much happens yet on the HA side since we only pass one IP in so its always going to fallback at this point --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e0b47f09d95..eac721a4462 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.3.0", + "aioesphomeapi==20.0.0", "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5022fd8e7a3..642068e15e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.3.0 +aioesphomeapi==20.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41ec51327dc..b6e7bb801d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.3.0 +aioesphomeapi==20.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 4ad16b56f2c20f253926ddf5543ee47d22ce8f65 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 12 Dec 2023 20:43:09 +0100 Subject: [PATCH 335/927] Fix setup Fast.com (#105580) * Fix setup fastdotcom * Add if --- homeassistant/components/fastdotcom/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index e872c3f501d..06c5fc7036a 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -31,15 +31,16 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Fast.com component. (deprecated).""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) ) - ) return True From d33aa6b8e7184c47bac6a1cdf295b9e003d79dab Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 12 Dec 2023 20:51:32 +0100 Subject: [PATCH 336/927] Migrate homematicip_cloud tests to use freezegun (#105592) --- .../homematicip_cloud/test_button.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index c4b83692267..5135c0ec48a 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,5 +1,6 @@ """Tests for HomematicIP Cloud button.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button.const import SERVICE_PRESS @@ -11,7 +12,7 @@ from .helper import get_and_check_entity_basics async def test_hmip_garage_door_controller_button( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, default_mock_hap_factory ) -> None: """Test HomematicipGarageDoorControllerButton.""" entity_id = "button.garagentor" @@ -28,13 +29,13 @@ async def test_hmip_garage_door_controller_button( assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state From 32147dbdd9d7455af988e25aaff6c32049118364 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 12 Dec 2023 20:52:59 +0100 Subject: [PATCH 337/927] Bump PyTado to 0.17.0 (#105573) --- homeassistant/components/tado/__init__.py | 39 ++++++++++----------- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index c9ed4b34c30..7faf918f8da 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -164,11 +164,10 @@ class TadoConnector: def setup(self): """Connect to Tado and fetch the zones.""" self.tado = Tado(self._username, self._password) - self.tado.setDebugging(True) # Load zones and devices - self.zones = self.tado.getZones() - self.devices = self.tado.getDevices() - tado_home = self.tado.getMe()["homes"][0] + self.zones = self.tado.get_zones() + self.devices = self.tado.get_devices() + tado_home = self.tado.get_me()["homes"][0] self.home_id = tado_home["id"] self.home_name = tado_home["name"] @@ -182,7 +181,7 @@ class TadoConnector: def update_devices(self): """Update the device data from Tado.""" try: - devices = self.tado.getDevices() + devices = self.tado.get_devices() except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating devices") return @@ -195,7 +194,7 @@ class TadoConnector: INSIDE_TEMPERATURE_MEASUREMENT in device["characteristics"]["capabilities"] ): - device[TEMP_OFFSET] = self.tado.getDeviceInfo( + device[TEMP_OFFSET] = self.tado.get_device_info( device_short_serial_no, TEMP_OFFSET ) except RuntimeError: @@ -223,7 +222,7 @@ class TadoConnector: def update_zones(self): """Update the zone data from Tado.""" try: - zone_states = self.tado.getZoneStates()["zoneStates"] + zone_states = self.tado.get_zone_states()["zoneStates"] except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zones") return @@ -235,7 +234,7 @@ class TadoConnector: """Update the internal data from Tado.""" _LOGGER.debug("Updating zone %s", zone_id) try: - data = self.tado.getZoneState(zone_id) + data = self.tado.get_zone_state(zone_id) except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) return @@ -256,8 +255,8 @@ class TadoConnector: def update_home(self): """Update the home data from Tado.""" try: - self.data["weather"] = self.tado.getWeather() - self.data["geofence"] = self.tado.getHomeState() + self.data["weather"] = self.tado.get_weather() + self.data["geofence"] = self.tado.get_home_state() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), @@ -270,15 +269,15 @@ class TadoConnector: def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" - return self.tado.getCapabilities(zone_id) + return self.tado.get_capabilities(zone_id) def get_auto_geofencing_supported(self): """Return whether the Tado Home supports auto geofencing.""" - return self.tado.getAutoGeofencingSupported() + return self.tado.get_auto_geofencing_supported() def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" - self.tado.resetZoneOverlay(zone_id) + self.tado.reset_zone_overlay(zone_id) self.update_zone(zone_id) def set_presence( @@ -287,11 +286,11 @@ class TadoConnector: ): """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: - self.tado.setAway() + self.tado.set_away() elif presence == PRESET_HOME: - self.tado.setHome() + self.tado.set_home() elif presence == PRESET_AUTO: - self.tado.setAuto() + self.tado.set_auto() # Update everything when changing modes self.update_zones() @@ -325,7 +324,7 @@ class TadoConnector: ) try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, temperature, @@ -333,7 +332,7 @@ class TadoConnector: device_type, "ON", mode, - fanSpeed=fan_speed, + fan_speed=fan_speed, swing=swing, ) @@ -345,7 +344,7 @@ class TadoConnector: def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) except RequestException as exc: @@ -356,6 +355,6 @@ class TadoConnector: def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" try: - self.tado.setTempOffset(device_id, offset) + self.tado.set_temp_offset(device_id, offset) except RequestException as exc: _LOGGER.error("Could not set temperature offset: %s", exc) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 62f7a377239..4c6a3eac2c5 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.15.0"] + "requirements": ["python-tado==0.17.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 642068e15e7..573d51cb43a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2222,7 +2222,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6e7bb801d8..6e61b196514 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1670,7 +1670,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 From 09b07c071bb1b95123c002f64087b246687ad8bd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 20:53:36 +0100 Subject: [PATCH 338/927] Add Apprise to strict typing (#105575) --- .strict-typing | 1 + homeassistant/components/apprise/notify.py | 5 +++-- mypy.ini | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index ab4cc944ea1..ce1491b33eb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -63,6 +63,7 @@ homeassistant.components.analytics.* homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* +homeassistant.components.apprise.* homeassistant.components.aqualogic.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index b215f93aeb1..e4b350c4da8 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import apprise import voluptuous as vol @@ -61,11 +62,11 @@ def get_service( class AppriseNotificationService(BaseNotificationService): """Implement the notification service for Apprise.""" - def __init__(self, a_obj): + def __init__(self, a_obj: apprise.Apprise) -> None: """Initialize the service.""" self.apprise = a_obj - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a specified target. If no target/tags are specified, then services are notified as is diff --git a/mypy.ini b/mypy.ini index 3e882d15812..df774c3a167 100644 --- a/mypy.ini +++ b/mypy.ini @@ -390,6 +390,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apprise.*] +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.aqualogic.*] check_untyped_defs = true disallow_incomplete_defs = true From 84bffcd2e141d76055d27ce633739a3dc4ef38aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 20:54:00 +0100 Subject: [PATCH 339/927] Add Aranet to strict typing (#105577) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index ce1491b33eb..f8361ba82dd 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,6 +65,7 @@ homeassistant.components.anthemav.* homeassistant.components.apcupsd.* homeassistant.components.apprise.* homeassistant.components.aqualogic.* +homeassistant.components.aranet.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* homeassistant.components.asuswrt.* diff --git a/mypy.ini b/mypy.ini index df774c3a167..ae0d18562ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -410,6 +410,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aranet.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true From 0e5d72a501dabb9b3c4209a98efab9ead488c932 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 20:54:35 +0100 Subject: [PATCH 340/927] Add Android IP webcam to strict typing (#105570) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index f8361ba82dd..e2a630f0179 100644 --- a/.strict-typing +++ b/.strict-typing @@ -60,6 +60,7 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.android_ip_webcam.* homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* diff --git a/mypy.ini b/mypy.ini index ae0d18562ee..94ff0c1e46d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -360,6 +360,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.android_ip_webcam.*] +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.anova.*] check_untyped_defs = true disallow_incomplete_defs = true From 8bd265c3aedd1f02fd066b48c240f9a25eeb816c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 12 Dec 2023 21:18:12 +0100 Subject: [PATCH 341/927] Add Fastdotcom service (#105553) * Add service for manual control * Proper naming * Removing old translation * Reverting back service * Removig services.yaml * Putting back in service * Putting back in service description and yaml * Proper naming * Adding create_issue * Feedback fixes * Fix deprecation date in strings * Update homeassistant/components/fastdotcom/__init__.py * Update homeassistant/components/fastdotcom/strings.json --------- Co-authored-by: G Johansson --- .../components/fastdotcom/__init__.py | 19 ++++++++++++++++++- .../components/fastdotcom/strings.json | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 06c5fc7036a..165d81edd0b 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -7,7 +7,8 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, Event, HomeAssistant +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -52,6 +53,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Request a refresh.""" await coordinator.async_request_refresh() + async def _request_refresh_service(call: ServiceCall) -> None: + """Request a refresh via the service.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + await coordinator.async_request_refresh() + if hass.state == CoreState.running: await coordinator.async_config_entry_first_refresh() else: @@ -59,6 +74,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.services.async_register(DOMAIN, "speedtest", _request_refresh_service) + await hass.config_entries.async_forward_entry_setups( entry, PLATFORMS, diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index d274ca8d679..61a1f686747 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -21,5 +21,18 @@ "name": "Speed test", "description": "Immediately executes a speed test with Fast.com." } + }, + "issues": { + "service_deprecation": { + "title": "Fast.com speedtest service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::fastdotcom::issues::service_deprecation::title%]", + "description": "Use `homeassistant.update_entity` instead to update the data.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to fix this issue." + } + } + } + } } } From 5bd0833f49c7f223a503b124f6e326b6cb1e8d17 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Dec 2023 21:19:41 +0100 Subject: [PATCH 342/927] Improve FrozenOrThawed (#105541) --- homeassistant/util/frozen_dataclass_compat.py | 51 +++++-------- tests/helpers/snapshots/test_entity.ambr | 74 ++++++++++++++++++- tests/helpers/test_entity.py | 43 +++++++++++ 3 files changed, 135 insertions(+), 33 deletions(-) diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index e62e0a34cf1..58faedeea6f 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -59,7 +59,7 @@ class FrozenOrThawed(type): for base in bases: dataclass_bases.append(getattr(base, "_dataclass", base)) cls._dataclass = dataclasses.make_dataclass( - f"{name}_dataclass", class_fields, bases=tuple(dataclass_bases), frozen=True + name, class_fields, bases=tuple(dataclass_bases), frozen=True ) def __new__( @@ -87,15 +87,17 @@ class FrozenOrThawed(type): class will be a real dataclass, i.e. it's decorated with @dataclass. """ if not namespace["_FrozenOrThawed__frozen_or_thawed"]: - parent = cls.__mro__[1] # This class is a real dataclass, optionally inject the parent's annotations - if dataclasses.is_dataclass(parent) or not hasattr(parent, "_dataclass"): - # Rely on dataclass inheritance + if all(dataclasses.is_dataclass(base) for base in bases): + # All direct parents are dataclasses, rely on dataclass inheritance return - # Parent is not a dataclass, inject its annotations - cls.__annotations__ = ( - parent._dataclass.__annotations__ | cls.__annotations__ - ) + # Parent is not a dataclass, inject all parents' annotations + annotations: dict = {} + for parent in cls.__mro__[::-1]: + if parent is object: + continue + annotations |= parent.__annotations__ + cls.__annotations__ = annotations return # First try without setting the kw_only flag, and if that fails, try setting it @@ -104,30 +106,15 @@ class FrozenOrThawed(type): except TypeError: cls._make_dataclass(name, bases, True) - def __delattr__(self: object, name: str) -> None: - """Delete an attribute. + def __new__(*args: Any, **kwargs: Any) -> object: + """Create a new instance. - If self is a real dataclass, this is called if the dataclass is not frozen. - If self is not a real dataclass, forward to cls._dataclass.__delattr. + The function has no named arguments to avoid name collisions with dataclass + field names. """ - if dataclasses.is_dataclass(self): - return object.__delattr__(self, name) - return self._dataclass.__delattr__(self, name) # type: ignore[attr-defined, no-any-return] + cls, *_args = args + if dataclasses.is_dataclass(cls): + return object.__new__(cls) + return cls._dataclass(*_args, **kwargs) - def __setattr__(self: object, name: str, value: Any) -> None: - """Set an attribute. - - If self is a real dataclass, this is called if the dataclass is not frozen. - If self is not a real dataclass, forward to cls._dataclass.__setattr__. - """ - if dataclasses.is_dataclass(self): - return object.__setattr__(self, name, value) - return self._dataclass.__setattr__(self, name, value) # type: ignore[attr-defined, no-any-return] - - # Set generated dunder methods from the dataclass - # MyPy doesn't understand what's happening, so we ignore it - cls.__delattr__ = __delattr__ # type: ignore[assignment, method-assign] - cls.__eq__ = cls._dataclass.__eq__ # type: ignore[method-assign] - cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] - cls.__repr__ = cls._dataclass.__repr__ # type: ignore[method-assign] - cls.__setattr__ = __setattr__ # type: ignore[assignment, method-assign] + cls.__new__ = __new__ # type: ignore[method-assign] diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index 3b04286b62f..7f146fa0494 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -1,6 +1,18 @@ # serializer version: 1 # name: test_entity_description_as_dataclass - EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None) + dict({ + 'device_class': 'test', + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': , + 'translation_key': None, + 'unit_of_measurement': None, + }) # --- # name: test_entity_description_as_dataclass.1 "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" @@ -43,3 +55,63 @@ # name: test_extending_entity_description.3 "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- +# name: test_extending_entity_description.4 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extension': 'ext', + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.5 + "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extension='ext', extra='foo')" +# --- +# name: test_extending_entity_description.6 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.7 + "test_extending_entity_description..ComplexEntityDescription1(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.8 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.9 + "test_extending_entity_description..ComplexEntityDescription2(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 66ba9f947c9..5a706b73b49 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1669,6 +1669,7 @@ def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): with pytest.raises(dataclasses.FrozenInstanceError): delattr(obj, "name") + assert dataclasses.is_dataclass(obj) assert obj == snapshot assert obj == entity.EntityDescription("blah", device_class="test") assert repr(obj) == snapshot @@ -1706,3 +1707,45 @@ def test_extending_entity_description(snapshot: SnapshotAssertion): assert obj.name == "mutate" delattr(obj, "key") assert not hasattr(obj, "key") + + # Try multiple levels of FrozenOrThawed + class ExtendedEntityDescription(entity.EntityDescription, frozen_or_thawed=True): + extension: str = None + + @dataclasses.dataclass(frozen=True) + class MyExtendedEntityDescription(ExtendedEntityDescription): + extra: str = None + + obj = MyExtendedEntityDescription("blah", extension="ext", extra="foo", name="name") + assert obj == snapshot + assert obj == MyExtendedEntityDescription( + "blah", extension="ext", extra="foo", name="name" + ) + assert repr(obj) == snapshot + + # Try multiple direct parents + @dataclasses.dataclass(frozen=True) + class MyMixin: + mixin: str = None + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription1(MyMixin, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription1(key="blah", extra="foo", mixin="mixin", name="name") + assert obj == snapshot + assert obj == ComplexEntityDescription1( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription2(entity.EntityDescription, MyMixin): + extra: str = None + + obj = ComplexEntityDescription2(key="blah", extra="foo", mixin="mixin", name="name") + assert obj == snapshot + assert obj == ComplexEntityDescription2( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot From f002a6a73225284874063c221de1f0e537e9d4e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 10:28:43 -1000 Subject: [PATCH 343/927] Refactor all Bluetooth scanners to inherit from BaseHaRemoteScanner (#105523) --- .../components/bluetooth/__init__.py | 3 +- .../components/bluetooth/base_scanner.py | 95 ------------------- homeassistant/components/bluetooth/manager.py | 41 +++++++- .../components/esphome/bluetooth/__init__.py | 2 +- .../components/esphome/bluetooth/scanner.py | 7 +- .../components/ruuvi_gateway/bluetooth.py | 7 +- .../components/shelly/bluetooth/__init__.py | 4 +- .../components/shelly/bluetooth/scanner.py | 7 +- tests/components/bluetooth/test_api.py | 6 +- .../components/bluetooth/test_base_scanner.py | 22 ++--- .../components/bluetooth/test_diagnostics.py | 9 +- tests/components/bluetooth/test_manager.py | 10 +- tests/components/bluetooth/test_models.py | 19 ++-- tests/components/bluetooth/test_wrappers.py | 13 +-- .../esphome/bluetooth/test_client.py | 2 +- tests/components/shelly/test_diagnostics.py | 1 - 16 files changed, 83 insertions(+), 165 deletions(-) delete mode 100644 homeassistant/components/bluetooth/base_scanner.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 4a53347e826..c4434f8695f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -23,6 +23,7 @@ from bluetooth_adapters import ( ) from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( + BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice, BluetoothScanningMode, @@ -69,7 +70,6 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import HomeAssistantRemoteScanner from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -116,6 +116,7 @@ __all__ = [ "BluetoothCallback", "BluetoothScannerDevice", "HaBluetoothConnector", + "BaseHaRemoteScanner", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "MONOTONIC_TIME", diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py deleted file mode 100644 index b8e1e909ad2..00000000000 --- a/homeassistant/components/bluetooth/base_scanner.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Base classes for HA Bluetooth scanners for bluetooth.""" -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -from bluetooth_adapters import DiscoveredDeviceAdvertisementData -from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector -from home_assistant_bluetooth import BluetoothServiceInfoBleak - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HomeAssistant, - callback as hass_callback, -) - -from . import models - - -class HomeAssistantRemoteScanner(BaseHaRemoteScanner): - """Home Assistant remote BLE scanner. - - This is the only object that should know about - the hass object. - """ - - __slots__ = ( - "hass", - "_storage", - "_cancel_stop", - ) - - def __init__( - self, - hass: HomeAssistant, - scanner_id: str, - name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - connector: HaBluetoothConnector | None, - connectable: bool, - ) -> None: - """Initialize the scanner.""" - self.hass = hass - assert models.MANAGER is not None - self._storage = models.MANAGER.storage - self._cancel_stop: CALLBACK_TYPE | None = None - super().__init__(scanner_id, name, new_info_callback, connector, connectable) - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - if history := self._storage.async_get_advertisement_history(self.source): - self._discovered_device_advertisement_datas = ( - history.discovered_device_advertisement_datas - ) - self._discovered_device_timestamps = history.discovered_device_timestamps - # Expire anything that is too old - self._async_expire_devices() - - self._cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._async_save_history - ) - return self._unsetup - - @hass_callback - def _unsetup(self) -> None: - super()._unsetup() - self._async_save_history() - if self._cancel_stop: - self._cancel_stop() - self._cancel_stop = None - - @hass_callback - def _async_save_history(self, event: Event | None = None) -> None: - """Save the history.""" - self._storage.async_set_advertisement_history( - self.source, - DiscoveredDeviceAdvertisementData( - self.connectable, - self._expire_seconds, - self._discovered_device_advertisement_datas, - self._discovered_device_timestamps, - ), - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - diag = await super().async_diagnostics() - diag["storage"] = self._storage.async_get_advertisement_history_as_dict( - self.source - ) - return diag diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 848460455ca..5508f58c82b 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -2,12 +2,13 @@ from __future__ import annotations from collections.abc import Callable, Iterable +from functools import partial import itertools import logging from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import BluetoothAdapters -from habluetooth import BluetoothManager +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -189,7 +190,45 @@ class HomeAssistantBluetoothManager(BluetoothManager): def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") + self._async_save_scanner_histories() super().async_stop() if self._cancel_logging_listener: self._cancel_logging_listener() self._cancel_logging_listener = None + + def _async_save_scanner_histories(self) -> None: + """Save the scanner histories.""" + for scanner in itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ): + self._async_save_scanner_history(scanner) + + def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: + """Save the scanner history.""" + if isinstance(scanner, BaseHaRemoteScanner): + self.storage.async_set_advertisement_history( + scanner.source, scanner.serialize_discovered_devices() + ) + + def _async_unregister_scanner( + self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE + ) -> None: + """Unregister a scanner.""" + unregister() + self._async_save_scanner_history(scanner) + + def async_register_scanner( + self, + scanner: BaseHaScanner, + connectable: bool, + connection_slots: int | None = None, + ) -> CALLBACK_TYPE: + """Register a scanner.""" + if isinstance(scanner, BaseHaRemoteScanner): + if history := self.storage.async_get_advertisement_history(scanner.source): + scanner.restore_discovered_devices(history) + + unregister = super().async_register_scanner( + scanner, connectable, connection_slots + ) + return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 6936afac714..0fe28730fce 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -99,7 +99,7 @@ async def async_connect_scanner( ), ) scanner = ESPHomeScanner( - hass, source, entry.title, new_info_callback, connector, connectable + source, entry.title, new_info_callback, connector, connectable ) client_data.scanner = scanner coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index b4fb12210d3..a54e7af59a6 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -7,14 +7,11 @@ from bluetooth_data_tools import ( parse_advertisement_data_tuple, ) -from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, - HomeAssistantRemoteScanner, -) +from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback -class ESPHomeScanner(HomeAssistantRemoteScanner): +class ESPHomeScanner(BaseHaRemoteScanner): """Scanner for esphome.""" __slots__ = () diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 8a154bca019..2d9bf8c6644 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -10,7 +10,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, - HomeAssistantRemoteScanner, + BaseHaRemoteScanner, async_get_advertisement_callback, async_register_scanner, ) @@ -22,12 +22,11 @@ from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class RuuviGatewayScanner(HomeAssistantRemoteScanner): +class RuuviGatewayScanner(BaseHaRemoteScanner): """Scanner for Ruuvi Gateway.""" def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, new_info_callback: Callable[[BluetoothServiceInfoBleak], None], @@ -36,7 +35,6 @@ class RuuviGatewayScanner(HomeAssistantRemoteScanner): ) -> None: """Initialize the scanner, using the given update coordinator as data source.""" super().__init__( - hass, scanner_id, name, new_info_callback, @@ -87,7 +85,6 @@ def async_connect_scanner( source, ) scanner = RuuviGatewayScanner( - hass=hass, scanner_id=source, name=entry.title, new_info_callback=async_get_advertisement_callback(hass), diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 429fae1a9a1..007900a5cdc 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -43,9 +43,7 @@ async def async_connect_scanner( source=source, can_connect=lambda: False, ) - scanner = ShellyBLEScanner( - hass, source, entry.title, new_info_callback, connector, False - ) + scanner = ShellyBLEScanner(source, entry.title, new_info_callback, connector, False) unload_callbacks = [ async_register_scanner(hass, scanner, False), scanner.async_setup(), diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 3ada1ce55f5..7c0dc3c792a 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,16 +6,13 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, - HomeAssistantRemoteScanner, -) +from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback from ..const import LOGGER -class ShellyBLEScanner(HomeAssistantRemoteScanner): +class ShellyBLEScanner(BaseHaRemoteScanner): """Scanner for shelly.""" @callback diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 30e9554f2af..aee15f7874e 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, - HomeAssistantRemoteScanner, async_scanner_by_source, async_scanner_devices_by_address, ) @@ -46,7 +46,7 @@ async def test_async_scanner_devices_by_address_connectable( """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() - class FakeInjectableScanner(HomeAssistantRemoteScanner): + class FakeInjectableScanner(BaseHaRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -68,7 +68,7 @@ async def test_async_scanner_devices_by_address_connectable( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) scanner = FakeInjectableScanner( - hass, "esp32", "esp32", new_info_callback, connector, False + "esp32", "esp32", new_info_callback, connector, False ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 2e2be0e7963..c94e3c874e0 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -14,8 +14,8 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BaseHaRemoteScanner, HaBluetoothConnector, - HomeAssistantRemoteScanner, storage, ) from homeassistant.components.bluetooth.const import ( @@ -41,7 +41,7 @@ from . import ( from tests.common import async_fire_time_changed, load_fixture -class FakeScanner(HomeAssistantRemoteScanner): +class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def inject_advertisement( @@ -115,7 +115,7 @@ async def test_remote_scanner( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -182,7 +182,7 @@ async def test_remote_scanner_expires_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -237,7 +237,7 @@ async def test_remote_scanner_expires_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -312,7 +312,7 @@ async def test_base_scanner_connecting_behavior( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -363,8 +363,7 @@ async def test_restore_history_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = HomeAssistantRemoteScanner( - hass, + scanner = BaseHaRemoteScanner( "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", lambda adv: None, @@ -379,8 +378,7 @@ async def test_restore_history_remote_adapter( cancel() unsetup() - scanner = HomeAssistantRemoteScanner( - hass, + scanner = BaseHaRemoteScanner( "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", lambda adv: None, @@ -419,7 +417,7 @@ async def test_device_with_ten_minute_advertising_interval( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -511,7 +509,7 @@ async def test_scanner_stops_responding( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index a69c26a16ea..8d87d5ef396 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -8,8 +8,8 @@ from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BaseHaRemoteScanner, HaBluetoothConnector, - HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -423,7 +423,7 @@ async def test_diagnostics_remote_adapter( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -458,9 +458,7 @@ async def test_diagnostics_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner( - hass, "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -631,7 +629,6 @@ async def test_diagnostics_remote_adapter( "scanning": True, "source": "esp32", "start_time": ANY, - "storage": None, "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "type": "FakeScanner", }, diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index ba28d8fa19c..63201f790fe 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -13,12 +13,12 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, - HomeAssistantRemoteScanner, async_ble_device_from_address, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -703,7 +703,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -725,7 +725,6 @@ async def test_goes_unavailable_connectable_only_and_recovers( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) connectable_scanner = FakeScanner( - hass, "connectable", "connectable", new_info_callback, @@ -749,7 +748,6 @@ async def test_goes_unavailable_connectable_only_and_recovers( ) not_connectable_scanner = FakeScanner( - hass, "not_connectable", "not_connectable", new_info_callback, @@ -800,7 +798,6 @@ async def test_goes_unavailable_connectable_only_and_recovers( cancel_unavailable() connectable_scanner_2 = FakeScanner( - hass, "connectable", "connectable", new_info_callback, @@ -876,7 +873,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -904,7 +901,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) non_connectable_scanner = FakeScanner( - hass, "connectable", "connectable", new_info_callback, diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 7499f312cef..c0423aca357 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -11,9 +11,9 @@ from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper import pytest from homeassistant.components.bluetooth import ( + BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, - HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -154,7 +154,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -182,7 +182,6 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", lambda info: None, @@ -267,7 +266,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -292,7 +291,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) + scanner = FakeScanner("esp32", "esp32", lambda info: None, connector, True) cancel = manager.async_register_scanner(scanner, True) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" @@ -332,7 +331,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -357,7 +356,7 @@ async def test_ble_device_with_proxy_clear_cache( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) + scanner = FakeScanner("esp32", "esp32", lambda info: None, connector, True) cancel = manager.async_register_scanner(scanner, True) inject_advertisement_with_source( hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32" @@ -435,7 +434,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -463,7 +462,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", lambda info: None, @@ -549,7 +547,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(HomeAssistantRemoteScanner): + class FakeScanner(BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -577,7 +575,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", lambda info: None, diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 1d294d90d76..6ebba080f6a 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -17,10 +17,10 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, + BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, HomeAssistantBluetoothManager, - HomeAssistantRemoteScanner, async_get_advertisement_callback, ) from homeassistant.core import HomeAssistant @@ -36,12 +36,11 @@ def mock_shutdown(manager: HomeAssistantBluetoothManager) -> None: manager.shutdown = False -class FakeScanner(HomeAssistantRemoteScanner): +class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, new_info_callback: Callable[[BluetoothServiceInfoBleak], None], @@ -49,9 +48,7 @@ class FakeScanner(HomeAssistantRemoteScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__( - hass, scanner_id, name, new_info_callback, connector, connectable - ) + super().__init__(scanner_id, name, new_info_callback, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: @@ -187,10 +184,10 @@ def _generate_scanners_with_fake_devices(hass): new_info_callback = async_get_advertisement_callback(hass) scanner_hci0 = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", new_info_callback, None, True + "00:00:00:00:00:01", "hci0", new_info_callback, None, True ) scanner_hci1 = FakeScanner( - hass, "00:00:00:00:00:02", "hci1", new_info_callback, None, True + "00:00:00:00:00:02", "hci1", new_info_callback, None, True ) for device, adv_data in hci0_device_advs.values(): diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 7ed1403041d..d74766023d7 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -44,7 +44,7 @@ async def client_data_fixture( api_version=APIVersion(1, 9), title=ESP_NAME, scanner=ESPHomeScanner( - hass, ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True + ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True ), ) diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 13126db0a0e..3a9b548757b 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -130,7 +130,6 @@ async def test_rpc_config_entry_diagnostics( "scanning": True, "start_time": ANY, "source": "12:34:56:78:9A:BC", - "storage": None, "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } From 283ff4fadaaf5e46737f29841573922a3ff41940 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Dec 2023 21:29:18 +0100 Subject: [PATCH 344/927] Add Adax to strict typing (#105562) --- .strict-typing | 1 + homeassistant/components/adax/__init__.py | 2 +- homeassistant/components/adax/climate.py | 2 +- homeassistant/components/adax/config_flow.py | 8 ++++++-- mypy.ini | 10 ++++++++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index e2a630f0179..4ee01b15d1a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -43,6 +43,7 @@ homeassistant.components.abode.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* homeassistant.components.actiontec.* +homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 511fb746216..cf60d40631c 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -24,7 +24,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # convert title and unique_id to string if config_entry.version == 1: if isinstance(config_entry.unique_id, int): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_entry( # type: ignore[unreachable] config_entry, unique_id=str(config_entry.unique_id), title=str(config_entry.title), diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 7587bfc0799..34812f9e449 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -137,7 +137,7 @@ class LocalAdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, adax_data_handler, unique_id): + def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: """Initialize the heater.""" self._adax_data_handler = adax_data_handler self._attr_unique_id = unique_id diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index b9e8ef1abca..b614c968d48 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -36,7 +36,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" data_schema = vol.Schema( { @@ -59,7 +61,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_local() return await self.async_step_cloud() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the local step.""" data_schema = vol.Schema( {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str} diff --git a/mypy.ini b/mypy.ini index 94ff0c1e46d..cf590b53918 100644 --- a/mypy.ini +++ b/mypy.ini @@ -190,6 +190,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.adax.*] +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.adguard.*] check_untyped_defs = true disallow_incomplete_defs = true From d144d6c9abaa836b5cf97ed1ff323bd6755f79dd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 12 Dec 2023 21:40:11 +0100 Subject: [PATCH 345/927] Mark more entities secondary on Fully Kiosk Browser (#105595) --- homeassistant/components/fully_kiosk/button.py | 3 +++ homeassistant/components/fully_kiosk/number.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 9f4d60e9574..b16265ed467 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -54,16 +54,19 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( FullyButtonEntityDescription( key="toForeground", translation_key="to_foreground", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toForeground(), ), FullyButtonEntityDescription( key="toBackground", translation_key="to_background", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toBackground(), ), FullyButtonEntityDescription( key="loadStartUrl", translation_key="load_start_url", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.loadStartUrl(), ), ) diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 298a58e2a11..4203a64074d 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -46,6 +46,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( native_max_value=255, native_step=1, native_min_value=0, + entity_category=EntityCategory.CONFIG, ), ) From 77283704a54b6aa8d0456d7217b07c52d73970c7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 12 Dec 2023 22:36:11 +0100 Subject: [PATCH 346/927] Bump `brother` library, use `pysnmp-lextudio` with SNMP integration (#105591) --- homeassistant/components/brother/__init__.py | 15 +-- .../components/brother/manifest.json | 2 +- homeassistant/components/brother/utils.py | 8 +- .../components/snmp/device_tracker.py | 13 +-- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/components/snmp/sensor.py | 34 +++---- homeassistant/components/snmp/switch.py | 91 +++++++++---------- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/brother/__init__.py | 5 +- tests/components/brother/conftest.py | 4 - tests/components/snmp/conftest.py | 5 - 12 files changed, 69 insertions(+), 118 deletions(-) delete mode 100644 tests/components/snmp/conftest.py diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 0f8f94c73c4..27ac97a27dc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,23 +4,18 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging -import sys -from typing import Any + +from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine -if sys.version_info < (3, 12): - from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError -else: - BrotherSensors = Any - PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) @@ -30,10 +25,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brother from a config entry.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Brother Printer is not supported on Python 3.12. Please use Python 3.11." - ) host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index cba44b68c6a..06b8574dbb4 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.3.0"], + "requirements": ["brother==3.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py index cd472b9b754..47b7ae31a67 100644 --- a/homeassistant/components/brother/utils.py +++ b/homeassistant/components/brother/utils.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging -import sys + +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio.cmdgen import lcd from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -10,10 +12,6 @@ from homeassistant.helpers import singleton from .const import DOMAIN, SNMP -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio.cmdgen import lcd - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 7ca31bae618..696b079fd5e 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -3,8 +3,9 @@ from __future__ import annotations import binascii import logging -import sys +from pysnmp.entity import config as cfg +from pysnmp.entity.rfc3413.oneliner import cmdgen import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -14,7 +15,6 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -26,11 +26,6 @@ from .const import ( DEFAULT_COMMUNITY, ) -if sys.version_info < (3, 12): - from pysnmp.entity import config as cfg - from pysnmp.entity.rfc3413.oneliner import cmdgen - - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -46,10 +41,6 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 324a1e49366..2756b97157c 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.21"] + "requirements": ["pysnmp-lextudio==5.0.31"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 58cd12d611f..a5915183ad0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -3,8 +3,20 @@ from __future__ import annotations from datetime import timedelta import logging -import sys +from pysnmp.error import PySnmpError +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + getCmd, +) import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA @@ -21,7 +33,6 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -56,21 +67,6 @@ from .const import ( SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - from pysnmp.error import PySnmpError - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - Udp6TransportTarget, - UdpTransportTarget, - UsmUserData, - getCmd, - ) - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -115,10 +111,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index e94c6991601..d0fe393d550 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -2,9 +2,34 @@ from __future__ import annotations import logging -import sys from typing import Any +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + UsmUserData, + getCmd, + setCmd, +) +from pysnmp.proto.rfc1902 import ( + Counter32, + Counter64, + Gauge32, + Integer, + Integer32, + IpAddress, + Null, + ObjectIdentifier, + OctetString, + Opaque, + TimeTicks, + Unsigned32, +) import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -17,7 +42,6 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,34 +67,6 @@ from .const import ( SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - UdpTransportTarget, - UsmUserData, - getCmd, - setCmd, - ) - from pysnmp.proto.rfc1902 import ( - Counter32, - Counter64, - Gauge32, - Integer, - Integer32, - IpAddress, - Null, - ObjectIdentifier, - OctetString, - Opaque, - TimeTicks, - Unsigned32, - ) - _LOGGER = logging.getLogger(__name__) CONF_COMMAND_OID = "command_oid" @@ -81,22 +77,21 @@ DEFAULT_COMMUNITY = "private" DEFAULT_PAYLOAD_OFF = 0 DEFAULT_PAYLOAD_ON = 1 -if sys.version_info < (3, 12): - MAP_SNMP_VARTYPES = { - "Counter32": Counter32, - "Counter64": Counter64, - "Gauge32": Gauge32, - "Integer32": Integer32, - "Integer": Integer, - "IpAddress": IpAddress, - "Null": Null, - # some work todo to support tuple ObjectIdentifier, this just supports str - "ObjectIdentifier": ObjectIdentifier, - "OctetString": OctetString, - "Opaque": Opaque, - "TimeTicks": TimeTicks, - "Unsigned32": Unsigned32, - } +MAP_SNMP_VARTYPES = { + "Counter32": Counter32, + "Counter64": Counter64, + "Gauge32": Gauge32, + "Integer32": Integer32, + "Integer": Integer, + "IpAddress": IpAddress, + "Null": Null, + # some work todo to support tuple ObjectIdentifier, this just supports str + "ObjectIdentifier": ObjectIdentifier, + "OctetString": OctetString, + "Opaque": Opaque, + "TimeTicks": TimeTicks, + "Unsigned32": Unsigned32, +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -132,10 +127,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/requirements_all.txt b/requirements_all.txt index 573d51cb43a..010d6bbc799 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -583,7 +583,7 @@ boto3==1.28.17 broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -2089,7 +2089,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e61b196514..daabb5e343c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -491,7 +491,7 @@ boschshcpy==0.2.75 broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -1588,7 +1588,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index 3176fa7fc28..8e24c2d8058 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,16 +1,13 @@ """Tests for Brother Printer integration.""" import json -import sys from unittest.mock import patch +from homeassistant.components.brother.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -if sys.version_info < (3, 12): - from homeassistant.components.brother.const import DOMAIN - async def init_integration( hass: HomeAssistant, skip_setup: bool = False diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 558b3b8ac3e..9e81cce9d12 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,13 +1,9 @@ """Test fixtures for brother.""" from collections.abc import Generator -import sys from unittest.mock import AsyncMock, patch import pytest -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] - @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/snmp/conftest.py b/tests/components/snmp/conftest.py deleted file mode 100644 index 05a518ad7f3..00000000000 --- a/tests/components/snmp/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection for Python 3.12.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] From 98b1bc9bed72cca6abdcc967ed30e56175c99067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 12:51:18 -1000 Subject: [PATCH 347/927] Bump aioesphomeapi to 20.1.0 (#105602) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index eac721a4462..0a22b3a4b59 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==20.0.0", + "aioesphomeapi==20.1.0", "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 010d6bbc799..a5876e4df4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==20.0.0 +aioesphomeapi==20.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daabb5e343c..9ca69fb466d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==20.0.0 +aioesphomeapi==20.1.0 # homeassistant.components.flo aioflo==2021.11.0 From a595cd7141a5179f4e49eb6d1bf251a273fdb19c Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Tue, 12 Dec 2023 18:52:15 -0500 Subject: [PATCH 348/927] Add sensor platform to A. O. Smith integration (#105604) * Add sensor platform to A. O. Smith integration * Fix typo * Remove unnecessary mixin * Simplify async_setup_entry --- homeassistant/components/aosmith/__init__.py | 2 +- homeassistant/components/aosmith/const.py | 6 ++ homeassistant/components/aosmith/sensor.py | 75 +++++++++++++++++++ homeassistant/components/aosmith/strings.json | 12 +++ .../aosmith/snapshots/test_sensor.ambr | 20 +++++ tests/components/aosmith/test_sensor.py | 27 +++++++ 6 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/aosmith/sensor.py create mode 100644 tests/components/aosmith/snapshots/test_sensor.ambr create mode 100644 tests/components/aosmith/test_sensor.py diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index af780e012ae..cac746e189e 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import AOSmithCoordinator -PLATFORMS: list[Platform] = [Platform.WATER_HEATER] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] @dataclass diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py index 06794582258..e79b993182e 100644 --- a/homeassistant/components/aosmith/const.py +++ b/homeassistant/components/aosmith/const.py @@ -14,3 +14,9 @@ REGULAR_INTERVAL = timedelta(seconds=30) # Update interval to be used while a mode or setpoint change is in progress. FAST_INTERVAL = timedelta(seconds=1) + +HOT_WATER_STATUS_MAP = { + "LOW": "low", + "MEDIUM": "medium", + "HIGH": "high", +} diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py new file mode 100644 index 00000000000..c9bd9f1321e --- /dev/null +++ b/homeassistant/components/aosmith/sensor.py @@ -0,0 +1,75 @@ +"""The sensor platform for the A. O. Smith integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import DOMAIN, HOT_WATER_STATUS_MAP +from .coordinator import AOSmithCoordinator +from .entity import AOSmithEntity + + +@dataclass(kw_only=True) +class AOSmithSensorEntityDescription(SensorEntityDescription): + """Define sensor entity description class.""" + + value_fn: Callable[[dict[str, Any]], str | int | None] + + +ENTITY_DESCRIPTIONS: tuple[AOSmithSensorEntityDescription, ...] = ( + AOSmithSensorEntityDescription( + key="hot_water_availability", + translation_key="hot_water_availability", + icon="mdi:water-thermometer", + device_class=SensorDeviceClass.ENUM, + options=["low", "medium", "high"], + value_fn=lambda device: HOT_WATER_STATUS_MAP.get( + device.get("data", {}).get("hotWaterStatus") + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith sensor platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + AOSmithSensorEntity(data.coordinator, description, junction_id) + for description in ENTITY_DESCRIPTIONS + for junction_id in data.coordinator.data + ) + + +class AOSmithSensorEntity(AOSmithEntity, SensorEntity): + """The sensor entity for the A. O. Smith integration.""" + + entity_description: AOSmithSensorEntityDescription + + def __init__( + self, + coordinator: AOSmithCoordinator, + description: AOSmithSensorEntityDescription, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self.entity_description = description + self._attr_unique_id = f"{description.key}_{junction_id}" + + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json index 26de264bab9..0f1fcfc1744 100644 --- a/homeassistant/components/aosmith/strings.json +++ b/homeassistant/components/aosmith/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": { + "hot_water_availability": { + "name": "Hot water availability", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } } } diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8499a98c8e5 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My water heater Hot water availability', + 'icon': 'mdi:water-thermometer', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_hot_water_availability', + 'last_changed': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py new file mode 100644 index 00000000000..99626b09e83 --- /dev/null +++ b/tests/components/aosmith/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the sensor platform of the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the sensor entity.""" + entry = entity_registry.async_get("sensor.my_water_heater_hot_water_availability") + assert entry + assert entry.unique_id == "hot_water_availability_junctionId" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the sensor entity.""" + state = hass.states.get("sensor.my_water_heater_hot_water_availability") + assert state == snapshot From 22f0e09b8c2d92fdbdf926c92c37900b4e4f648d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 15:20:05 -1000 Subject: [PATCH 349/927] Bump aioesphomeapi to 21.0.0 (#105609) --- homeassistant/components/esphome/bluetooth/scanner.py | 6 +++--- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index a54e7af59a6..ecbfeb4124c 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -1,7 +1,7 @@ """Bluetooth scanner for esphome.""" from __future__ import annotations -from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement +from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisementsResponse from bluetooth_data_tools import ( int_to_bluetooth_address, parse_advertisement_data_tuple, @@ -34,11 +34,11 @@ class ESPHomeScanner(BaseHaRemoteScanner): @callback def async_on_raw_advertisements( - self, advertisements: list[BluetoothLERawAdvertisement] + self, raw: BluetoothLERawAdvertisementsResponse ) -> None: """Call the registered callback.""" now = MONOTONIC_TIME() - for adv in advertisements: + for adv in raw.advertisements: self._async_on_advertisement( int_to_bluetooth_address(adv.address), adv.rssi, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0a22b3a4b59..a7712de14fa 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==20.1.0", + "aioesphomeapi==21.0.0", "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index a5876e4df4b..ff852542d0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==20.1.0 +aioesphomeapi==21.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ca69fb466d..bb14d282a21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==20.1.0 +aioesphomeapi==21.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 431a44ab673c8caffa6ad91bb986a1ec7f04b796 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 12 Dec 2023 21:54:15 -0600 Subject: [PATCH 350/927] Add name slot to HassClimateGetTemperature intent (#105585) --- homeassistant/components/climate/intent.py | 16 +++++++++++++++- tests/components/climate/test_intent.py | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 23cc3d5bcd2..4152fb5ee2d 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -21,7 +21,7 @@ class GetTemperatureIntent(intent.IntentHandler): """Handle GetTemperature intents.""" intent_type = INTENT_GET_TEMPERATURE - slot_schema = {vol.Optional("area"): str} + slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" @@ -49,6 +49,20 @@ class GetTemperatureIntent(intent.IntentHandler): if climate_state is None: raise intent.IntentHandleError(f"No climate entity in area {area_name}") + climate_entity = component.get_entity(climate_state.entity_id) + elif "name" in slots: + # Filter by name + entity_name = slots["name"]["value"] + + for maybe_climate in intent.async_match_states( + hass, name=entity_name, domains=[DOMAIN] + ): + climate_state = maybe_climate + break + + if climate_state is None: + raise intent.IntentHandleError(f"No climate entity named {entity_name}") + climate_entity = component.get_entity(climate_state.entity_id) else: # First entity diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index eaf7029d303..6473eca1b88 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -153,7 +153,7 @@ async def test_get_temperature( state = response.matched_states[0] assert state.attributes["current_temperature"] == 10.0 - # Select by area instead (climate_2) + # Select by area (climate_2) response = await intent.async_handle( hass, "test", @@ -166,6 +166,19 @@ async def test_get_temperature( state = response.matched_states[0] assert state.attributes["current_temperature"] == 22.0 + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + async def test_get_temperature_no_entities( hass: HomeAssistant, From a73e86a74160a71354c433acb1c104480dd15e41 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 12 Dec 2023 23:21:16 -0600 Subject: [PATCH 351/927] Skip TTS events entirely with empty text (#105617) --- .../components/assist_pipeline/pipeline.py | 60 ++++++++++--------- .../snapshots/test_websocket.ambr | 28 +++++++-- .../assist_pipeline/test_websocket.py | 11 ++-- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index ed9029d1c2c..26d599da836 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -369,6 +369,7 @@ class PipelineStage(StrEnum): STT = "stt" INTENT = "intent" TTS = "tts" + END = "end" PIPELINE_STAGE_ORDER = [ @@ -1024,35 +1025,32 @@ class PipelineRun: ) ) - if tts_input := tts_input.strip(): - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) - tts_output = { - "media_id": tts_media_id, - **asdict(tts_media), - } - else: - tts_output = {} + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } self.process_event( PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) @@ -1345,7 +1343,11 @@ class PipelineInput: self.conversation_id, self.device_id, ) - current_stage = PipelineStage.TTS + if tts_input.strip(): + current_stage = PipelineStage.TTS + else: + # Skip TTS + current_stage = PipelineStage.END if self.run.end_stage != PipelineStage.INTENT: # text-to-speech diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 072b1ff730a..c165675a6ff 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -662,15 +662,33 @@ # --- # name: test_pipeline_empty_tts_output.1 dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': '', - 'voice': 'james_earl_jones', + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'never mind', + 'language': 'en', }) # --- # name: test_pipeline_empty_tts_output.2 dict({ - 'tts_output': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), }), }) # --- diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 0e2a3ad538c..458320a9a90 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2467,10 +2467,10 @@ async def test_pipeline_empty_tts_output( await client.send_json_auto_id( { "type": "assist_pipeline/run", - "start_stage": "tts", + "start_stage": "intent", "end_stage": "tts", "input": { - "text": "", + "text": "never mind", }, } ) @@ -2486,16 +2486,15 @@ async def test_pipeline_empty_tts_output( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text-to-speech + # intent msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" + assert msg["event"]["type"] == "intent-start" assert msg["event"]["data"] == snapshot events.append(msg["event"]) msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" + assert msg["event"]["type"] == "intent-end" assert msg["event"]["data"] == snapshot - assert not msg["event"]["data"]["tts_output"] events.append(msg["event"]) # run end From 9e9b5184337e2a8383b61bcf21a6ccbcc681617f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 07:38:42 +0100 Subject: [PATCH 352/927] Bump github/codeql-action from 2.22.9 to 2.22.10 (#105620) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c9e6bb8fcc8..74cb3826a6c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.9 + uses: github/codeql-action/init@v2.22.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.9 + uses: github/codeql-action/analyze@v2.22.10 with: category: "/language:python" From 66d24b38aac6acbdf812e4ec9414b594f8b6452f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 13 Dec 2023 08:18:50 +0100 Subject: [PATCH 353/927] Add diagnostics platform to BraviaTV (#105603) * Add diagnostics platform * Add test * Improve test * Use consts * Fix test * Patch methods * Patch methods --- .../components/braviatv/diagnostics.py | 28 ++++++++ .../braviatv/snapshots/test_diagnostics.ambr | 37 ++++++++++ tests/components/braviatv/test_diagnostics.py | 72 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 homeassistant/components/braviatv/diagnostics.py create mode 100644 tests/components/braviatv/snapshots/test_diagnostics.ambr create mode 100644 tests/components/braviatv/test_diagnostics.py diff --git a/homeassistant/components/braviatv/diagnostics.py b/homeassistant/components/braviatv/diagnostics.py new file mode 100644 index 00000000000..f1822b545e9 --- /dev/null +++ b/homeassistant/components/braviatv/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for BraviaTV.""" +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import BraviaTVCoordinator + +TO_REDACT = {CONF_MAC, CONF_PIN, "macAddr"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: BraviaTVCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + device_info = await coordinator.client.get_system_info() + + diagnostics_data = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "device_info": async_redact_data(device_info, TO_REDACT), + } + + return diagnostics_data diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2fd515b24e5 --- /dev/null +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'mac': '**REDACTED**', + 'pin': '**REDACTED**', + 'use_psk': True, + }), + 'disabled_by': None, + 'domain': 'braviatv', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'very_unique_string', + 'version': 1, + }), + 'device_info': dict({ + 'area': 'POL', + 'cid': 'very_unique_string', + 'generation': '5.2.0', + 'language': 'pol', + 'macAddr': '**REDACTED**', + 'model': 'TV-Model', + 'name': 'BRAVIA', + 'product': 'TV', + 'region': 'XEU', + 'serial': 'serial_number', + }), + }) +# --- diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py new file mode 100644 index 00000000000..d0974774e7b --- /dev/null +++ b/tests/components/braviatv/test_diagnostics.py @@ -0,0 +1,72 @@ +"""Test the BraviaTV diagnostics.""" +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +BRAVIA_SYSTEM_INFO = { + "product": "TV", + "region": "XEU", + "language": "pol", + "model": "TV-Model", + "serial": "serial_number", + "macAddr": "AA:BB:CC:DD:EE:FF", + "name": "BRAVIA", + "generation": "5.2.0", + "area": "POL", + "cid": "very_unique_string", +} +INPUTS = [ + { + "uri": "extInput:hdmi?port=1", + "title": "HDMI 1", + "connection": False, + "label": "", + "icon": "meta:hdmi", + } +] + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_USE_PSK: True, + CONF_PIN: "12345qwerty", + }, + unique_id="very_unique_string", + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + + config_entry.add_to_hass(hass) + with patch("pybravia.BraviaClient.connect"), patch( + "pybravia.BraviaClient.pair" + ), patch("pybravia.BraviaClient.set_wol_mode"), patch( + "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO + ), patch("pybravia.BraviaClient.get_power_status", return_value="active"), patch( + "pybravia.BraviaClient.get_external_status", return_value=INPUTS + ), patch("pybravia.BraviaClient.get_volume_info", return_value={}), patch( + "pybravia.BraviaClient.get_playing_info", return_value={} + ), patch("pybravia.BraviaClient.get_app_list", return_value=[]), patch( + "pybravia.BraviaClient.get_content_list_all", return_value=[] + ): + assert await async_setup_component(hass, DOMAIN, {}) + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From aaccf190134a448bfeef62c0c8d53fdb73167b94 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Dec 2023 02:09:22 -0600 Subject: [PATCH 354/927] Rename "satellite enabled" to "mute" (#105619) --- homeassistant/components/wyoming/devices.py | 28 ++++++------ homeassistant/components/wyoming/satellite.py | 44 +++++++++---------- homeassistant/components/wyoming/strings.json | 4 +- homeassistant/components/wyoming/switch.py | 18 ++++---- tests/components/wyoming/test_devices.py | 20 ++++----- tests/components/wyoming/test_satellite.py | 26 +++++------ tests/components/wyoming/test_switch.py | 28 ++++++------ 7 files changed, 83 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index bd7252bcf6b..6865669fbf0 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -17,14 +17,14 @@ class SatelliteDevice: satellite_id: str device_id: str is_active: bool = False - is_enabled: bool = True + is_muted: bool = False pipeline_name: str | None = None noise_suppression_level: int = 0 auto_gain: int = 0 volume_multiplier: float = 1.0 _is_active_listener: Callable[[], None] | None = None - _is_enabled_listener: Callable[[], None] | None = None + _is_muted_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None _audio_settings_listener: Callable[[], None] | None = None @@ -37,12 +37,12 @@ class SatelliteDevice: self._is_active_listener() @callback - def set_is_enabled(self, enabled: bool) -> None: - """Set enabled state.""" - if enabled != self.is_enabled: - self.is_enabled = enabled - if self._is_enabled_listener is not None: - self._is_enabled_listener() + def set_is_muted(self, muted: bool) -> None: + """Set muted state.""" + if muted != self.is_muted: + self.is_muted = muted + if self._is_muted_listener is not None: + self._is_muted_listener() @callback def set_pipeline_name(self, pipeline_name: str) -> None: @@ -82,9 +82,9 @@ class SatelliteDevice: self._is_active_listener = is_active_listener @callback - def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: - """Listen for updates to is_enabled.""" - self._is_enabled_listener = is_enabled_listener + def set_is_muted_listener(self, is_muted_listener: Callable[[], None]) -> None: + """Listen for updates to muted status.""" + self._is_muted_listener = is_muted_listener @callback def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: @@ -105,11 +105,11 @@ class SatelliteDevice: "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" ) - def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: - """Return entity id for satellite enabled switch.""" + def get_muted_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite muted switch.""" ent_reg = er.async_get(hass) return ent_reg.async_get_entity_id( - "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + "switch", DOMAIN, f"{self.satellite_id}-mute" ) def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 2c93b762015..78f57ff4b01 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -49,7 +49,6 @@ class WyomingSatellite: self.hass = hass self.service = service self.device = device - self.is_enabled = True self.is_running = True self._client: AsyncTcpClient | None = None @@ -57,9 +56,9 @@ class WyomingSatellite: self._is_pipeline_running = False self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None - self._enabled_changed_event = asyncio.Event() + self._muted_changed_event = asyncio.Event() - self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_is_muted_listener(self._muted_changed) self.device.set_pipeline_listener(self._pipeline_changed) self.device.set_audio_settings_listener(self._audio_settings_changed) @@ -70,11 +69,11 @@ class WyomingSatellite: try: while self.is_running: try: - # Check if satellite has been disabled - while not self.device.is_enabled: - await self.on_disabled() + # Check if satellite has been muted + while self.device.is_muted: + await self.on_muted() if not self.is_running: - # Satellite was stopped while waiting to be enabled + # Satellite was stopped while waiting to be unmuted return # Connect and run pipeline loop @@ -93,8 +92,8 @@ class WyomingSatellite: """Signal satellite task to stop running.""" self.is_running = False - # Unblock waiting for enabled - self._enabled_changed_event.set() + # Unblock waiting for unmuted + self._muted_changed_event.set() async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" @@ -112,9 +111,9 @@ class WyomingSatellite: ) await asyncio.sleep(_RECONNECT_SECONDS) - async def on_disabled(self) -> None: - """Block until device may be enabled again.""" - await self._enabled_changed_event.wait() + async def on_muted(self) -> None: + """Block until device may be unmated again.""" + await self._muted_changed_event.wait() async def on_stopped(self) -> None: """Run when run() has fully stopped.""" @@ -122,15 +121,14 @@ class WyomingSatellite: # ------------------------------------------------------------------------- - def _enabled_changed(self) -> None: - """Run when device enabled status changes.""" - - if not self.device.is_enabled: + def _muted_changed(self) -> None: + """Run when device muted status changes.""" + if self.device.is_muted: # Cancel any running pipeline self._audio_queue.put_nowait(None) - self._enabled_changed_event.set() - self._enabled_changed_event.clear() + self._muted_changed_event.set() + self._muted_changed_event.clear() def _pipeline_changed(self) -> None: """Run when device pipeline changes.""" @@ -148,7 +146,7 @@ class WyomingSatellite: """Run pipelines until an error occurs.""" self.device.set_is_active(False) - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): try: await self._connect() break @@ -158,7 +156,7 @@ class WyomingSatellite: assert self._client is not None _LOGGER.debug("Connected to satellite") - if (not self.is_running) or (not self.is_enabled): + if (not self.is_running) or self.device.is_muted: # Run was cancelled or satellite was disabled during connection return @@ -167,7 +165,7 @@ class WyomingSatellite: # Wait until we get RunPipeline event run_pipeline: RunPipeline | None = None - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): run_event = await self._client.read_event() if run_event is None: raise ConnectionResetError("Satellite disconnected") @@ -181,7 +179,7 @@ class WyomingSatellite: assert run_pipeline is not None _LOGGER.debug("Received run information: %s", run_pipeline) - if (not self.is_running) or (not self.is_enabled): + if (not self.is_running) or self.device.is_muted: # Run was cancelled or satellite was disabled while waiting for # RunPipeline event. return @@ -196,7 +194,7 @@ class WyomingSatellite: raise ValueError(f"Invalid end stage: {end_stage}") # Each loop is a pipeline run - while self.is_running and self.is_enabled: + while self.is_running and (not self.device.is_muted): # Use select to get pipeline each time in case it's changed pipeline_id = pipeline_select.get_chosen_pipeline( self.hass, diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 7b6be68aeb2..f2768e45eb8 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -49,8 +49,8 @@ } }, "switch": { - "satellite_enabled": { - "name": "Satellite enabled" + "mute": { + "name": "Mute" } }, "number": { diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 2bc43122588..7366a52efab 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -29,17 +29,17 @@ async def async_setup_entry( # Setup is only forwarded for satellites assert item.satellite is not None - async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + async_add_entities([WyomingSatelliteMuteSwitch(item.satellite.device)]) -class WyomingSatelliteEnabledSwitch( +class WyomingSatelliteMuteSwitch( WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity ): - """Entity to represent if satellite is enabled.""" + """Entity to represent if satellite is muted.""" entity_description = SwitchEntityDescription( - key="satellite_enabled", - translation_key="satellite_enabled", + key="mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, ) @@ -49,17 +49,17 @@ class WyomingSatelliteEnabledSwitch( state = await self.async_get_last_state() - # Default to on - self._attr_is_on = (state is None) or (state.state == STATE_ON) + # Default to off + self._attr_is_on = (state is not None) and (state.state == STATE_ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" self._attr_is_on = True self.async_write_ha_state() - self._device.set_is_enabled(True) + self._device.set_is_muted(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" self._attr_is_on = False self.async_write_ha_state() - self._device.set_is_enabled(False) + self._device.set_is_muted(False) diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py index 549f76f20f1..0273a7da275 100644 --- a/tests/components/wyoming/test_devices.py +++ b/tests/components/wyoming/test_devices.py @@ -5,7 +5,7 @@ from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED from homeassistant.components.wyoming import DOMAIN from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -34,11 +34,11 @@ async def test_device_registry_info( assert assist_in_progress_state is not None assert assist_in_progress_state.state == STATE_OFF - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id - satellite_enabled_state = hass.states.get(satellite_enabled_id) - assert satellite_enabled_state is not None - assert satellite_enabled_state.state == STATE_ON + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + muted_state = hass.states.get(muted_id) + assert muted_state is not None + assert muted_state.state == STATE_OFF pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) assert pipeline_entity_id @@ -59,9 +59,9 @@ async def test_remove_device_registry_entry( assert assist_in_progress_id assert hass.states.get(assist_in_progress_id) is not None - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id - assert hass.states.get(satellite_enabled_id) is not None + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + assert hass.states.get(muted_id) is not None pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) assert pipeline_entity_id @@ -74,5 +74,5 @@ async def test_remove_device_registry_entry( # Everything should be gone assert hass.states.get(assist_in_progress_id) is None - assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(muted_id) is None assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 83e4d98d971..07a6aa8925e 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -196,7 +196,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await mock_client.detect_event.wait() assert not device.is_active - assert device.is_enabled + assert not device.is_muted # Wake word is detected event_callback( @@ -312,36 +312,36 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_satellite_disabled(hass: HomeAssistant) -> None: - """Test callback for a satellite that has been disabled.""" - on_disabled_event = asyncio.Event() +async def test_satellite_muted(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been muted.""" + on_muted_event = asyncio.Event() original_make_satellite = wyoming._make_satellite - def make_disabled_satellite( + def make_muted_satellite( hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService ): satellite = original_make_satellite(hass, config_entry, service) - satellite.device.set_is_enabled(False) + satellite.device.set_is_muted(True) return satellite - async def on_disabled(self): - self.device.set_is_enabled(True) - on_disabled_event.set() + async def on_muted(self): + self.device.set_is_muted(False) + on_muted_event.set() with patch( "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, ), patch( - "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + "homeassistant.components.wyoming._make_satellite", make_muted_satellite ), patch( - "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", - on_disabled, + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", + on_muted, ): await setup_config_entry(hass) async with asyncio.timeout(1): - await on_disabled_event.wait() + await on_muted_event.wait() async def test_satellite_restart(hass: HomeAssistant) -> None: diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index a39b7087f6d..6246ba95003 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -7,35 +7,35 @@ from homeassistant.core import HomeAssistant from . import reload_satellite -async def test_satellite_enabled( +async def test_muted( hass: HomeAssistant, satellite_config_entry: ConfigEntry, satellite_device: SatelliteDevice, ) -> None: - """Test satellite enabled.""" - satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) - assert satellite_enabled_id + """Test satellite muted.""" + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id - state = hass.states.get(satellite_enabled_id) + state = hass.states.get(muted_id) assert state is not None - assert state.state == STATE_ON - assert satellite_device.is_enabled + assert state.state == STATE_OFF + assert not satellite_device.is_muted await hass.services.async_call( "switch", - "turn_off", - {"entity_id": satellite_enabled_id}, + "turn_on", + {"entity_id": muted_id}, blocking=True, ) - state = hass.states.get(satellite_enabled_id) + state = hass.states.get(muted_id) assert state is not None - assert state.state == STATE_OFF - assert not satellite_device.is_enabled + assert state.state == STATE_ON + assert satellite_device.is_muted # test restore satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) - state = hass.states.get(satellite_enabled_id) + state = hass.states.get(muted_id) assert state is not None - assert state.state == STATE_OFF + assert state.state == STATE_ON From 5dbd0dede1556be103e0588ddafa27c7829500d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Dec 2023 22:17:48 -1000 Subject: [PATCH 355/927] Refactor Bluetooth scanners to avoid the need to pass a callback (#105607) --- .../components/bluetooth/__init__.py | 4 ++-- .../components/bluetooth/manifest.json | 2 +- .../components/esphome/bluetooth/__init__.py | 6 +----- .../components/ruuvi_gateway/bluetooth.py | 7 ------- .../components/shelly/bluetooth/__init__.py | 4 +--- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_api.py | 13 ++++++++---- .../components/bluetooth/test_base_scanner.py | 20 ++++++------------- .../components/bluetooth/test_diagnostics.py | 3 +-- tests/components/bluetooth/test_manager.py | 7 ------- tests/components/bluetooth/test_models.py | 7 ++----- tests/components/bluetooth/test_wrappers.py | 15 +++----------- .../esphome/bluetooth/test_client.py | 4 +--- 15 files changed, 30 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c4434f8695f..234712bddaf 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -106,6 +106,7 @@ __all__ = [ "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", + "async_get_advertisement_callback", "BaseHaScanner", "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", @@ -287,9 +288,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE - new_info_callback = async_get_advertisement_callback(hass) manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(mode, adapter, address, new_info_callback) + scanner = HaScanner(mode, adapter, address) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5abec24b6d1..a4c96c91727 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.21.0", - "habluetooth==0.11.1" + "habluetooth==1.0.0" ] } diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 0fe28730fce..e7dd0697987 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -11,7 +11,6 @@ from aioesphomeapi import APIClient, BluetoothProxyFeature from homeassistant.components.bluetooth import ( HaBluetoothConnector, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.config_entries import ConfigEntry @@ -63,7 +62,6 @@ async def async_connect_scanner( """Connect scanner.""" assert entry.unique_id is not None source = str(entry.unique_id) - new_info_callback = async_get_advertisement_callback(hass) device_info = entry_data.device_info assert device_info is not None feature_flags = device_info.bluetooth_proxy_feature_flags_compat( @@ -98,9 +96,7 @@ async def async_connect_scanner( partial(_async_can_connect, entry_data, bluetooth_device, source) ), ) - scanner = ESPHomeScanner( - source, entry.title, new_info_callback, connector, connectable - ) + scanner = ESPHomeScanner(source, entry.title, connector, connectable) client_data.scanner = scanner coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] # These calls all return a callback that can be used to unsubscribe diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 2d9bf8c6644..d3cf1e81379 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -1,17 +1,13 @@ """Bluetooth support for Ruuvi Gateway.""" from __future__ import annotations -from collections.abc import Callable import logging import time -from home_assistant_bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.config_entries import ConfigEntry @@ -29,7 +25,6 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): self, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], *, coordinator: RuuviGatewayUpdateCoordinator, ) -> None: @@ -37,7 +32,6 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): super().__init__( scanner_id, name, - new_info_callback, connector=None, connectable=False, ) @@ -87,7 +81,6 @@ def async_connect_scanner( scanner = RuuviGatewayScanner( scanner_id=source, name=entry.title, - new_info_callback=async_get_advertisement_callback(hass), coordinator=coordinator, ) unload_callbacks = [ diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 007900a5cdc..92c630323ba 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -14,7 +14,6 @@ from aioshelly.ble.const import ( from homeassistant.components.bluetooth import ( HaBluetoothConnector, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -36,14 +35,13 @@ async def async_connect_scanner( device = coordinator.device entry = coordinator.entry source = format_mac(coordinator.mac).upper() - new_info_callback = async_get_advertisement_callback(hass) connector = HaBluetoothConnector( # no active connections to shelly yet client=None, # type: ignore[arg-type] source=source, can_connect=lambda: False, ) - scanner = ShellyBLEScanner(source, entry.title, new_info_callback, connector, False) + scanner = ShellyBLEScanner(source, entry.title, connector, False) unload_callbacks = [ async_register_scanner(hass, scanner, False), scanner.async_setup(), diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 53ed955f791..1832c61712e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.11.1 +habluetooth==1.0.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index ff852542d0d..2fdbe18fa27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.11.1 +habluetooth==1.0.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb14d282a21..148d0597d8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.11.1 +habluetooth==1.0.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index aee15f7874e..732fce4c8e2 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -40,6 +40,14 @@ async def test_monotonic_time() -> None: assert MONOTONIC_TIME() == pytest.approx(time.monotonic(), abs=0.1) +async def test_async_get_advertisement_callback( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test getting advertisement callback.""" + callback = bluetooth.async_get_advertisement_callback(hass) + assert callback is not None + + async def test_async_scanner_devices_by_address_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: @@ -63,13 +71,10 @@ async def test_async_scanner_devices_by_address_connectable( MONOTONIC_TIME(), ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeInjectableScanner( - "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeInjectableScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) switchbot_device = generate_ble_device( diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index c94e3c874e0..4f60fc9ef9b 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -111,11 +111,10 @@ async def test_remote_scanner( rssi=-100, ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -178,11 +177,10 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -233,11 +231,10 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -308,11 +305,10 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -366,7 +362,6 @@ async def test_restore_history_remote_adapter( scanner = BaseHaRemoteScanner( "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) @@ -381,7 +376,6 @@ async def test_restore_history_remote_adapter( scanner = BaseHaRemoteScanner( "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) @@ -413,11 +407,10 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) @@ -505,11 +498,10 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 8d87d5ef396..f70c301dcfe 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -454,11 +454,10 @@ async def test_diagnostics_remote_adapter( assert await hass.config_entries.async_setup(entry1.entry_id) await hass.async_block_till_done() - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, False) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 63201f790fe..2a470feacfa 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -20,7 +20,6 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, HaBluetoothConnector, async_ble_device_from_address, - async_get_advertisement_callback, async_get_fallback_availability_interval, async_get_learned_advertising_interval, async_scanner_count, @@ -720,14 +719,12 @@ async def test_goes_unavailable_connectable_only_and_recovers( MONOTONIC_TIME(), ) - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) connectable_scanner = FakeScanner( "connectable", "connectable", - new_info_callback, connector, True, ) @@ -750,7 +747,6 @@ async def test_goes_unavailable_connectable_only_and_recovers( not_connectable_scanner = FakeScanner( "not_connectable", "not_connectable", - new_info_callback, connector, False, ) @@ -800,7 +796,6 @@ async def test_goes_unavailable_connectable_only_and_recovers( connectable_scanner_2 = FakeScanner( "connectable", "connectable", - new_info_callback, connector, True, ) @@ -896,14 +891,12 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( self._discovered_device_timestamps.clear() self._previous_service_info.clear() - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) non_connectable_scanner = FakeScanner( "connectable", "connectable", - new_info_callback, connector, False, ) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index c0423aca357..6e8181b5a22 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -184,7 +184,6 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( scanner = FakeScanner( "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) @@ -291,7 +290,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False) - scanner = FakeScanner("esp32", "esp32", lambda info: None, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) cancel = manager.async_register_scanner(scanner, True) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" @@ -356,7 +355,7 @@ async def test_ble_device_with_proxy_clear_cache( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) - scanner = FakeScanner("esp32", "esp32", lambda info: None, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) cancel = manager.async_register_scanner(scanner, True) inject_advertisement_with_source( hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32" @@ -464,7 +463,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab scanner = FakeScanner( "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) @@ -577,7 +575,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab scanner = FakeScanner( "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 6ebba080f6a..78ec5bd16ac 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -1,7 +1,6 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations -from collections.abc import Callable from contextlib import contextmanager from unittest.mock import patch @@ -18,10 +17,8 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, - BluetoothServiceInfoBleak, HaBluetoothConnector, HomeAssistantBluetoothManager, - async_get_advertisement_callback, ) from homeassistant.core import HomeAssistant @@ -43,12 +40,11 @@ class FakeScanner(BaseHaRemoteScanner): self, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], connector: None, connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__(scanner_id, name, new_info_callback, connector, connectable) + super().__init__(scanner_id, name, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: @@ -182,13 +178,8 @@ def _generate_scanners_with_fake_devices(hass): ) hci1_device_advs[device.address] = (device, adv_data) - new_info_callback = async_get_advertisement_callback(hass) - scanner_hci0 = FakeScanner( - "00:00:00:00:00:01", "hci0", new_info_callback, None, True - ) - scanner_hci1 = FakeScanner( - "00:00:00:00:00:02", "hci1", new_info_callback, None, True - ) + scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True) + scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True) for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index d74766023d7..e770c75cf03 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -43,9 +43,7 @@ async def client_data_fixture( ), api_version=APIVersion(1, 9), title=ESP_NAME, - scanner=ESPHomeScanner( - ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True - ), + scanner=ESPHomeScanner(ESP_MAC_ADDRESS, ESP_NAME, connector, True), ) From c318445a761d9b96c832cf094e2363a7a01d570d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 13 Dec 2023 09:22:10 +0100 Subject: [PATCH 356/927] Write Enphase Envoy data to log when in debug mode (#105456) --- homeassistant/components/enphase_envoy/coordinator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 75f2ef39289..02a9d2f2491 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -144,7 +144,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self._setup_complete: await self._async_setup_and_authenticate() self._async_mark_setup_complete() - return (await envoy.update()).raw + # dump all received data in debug mode to assist troubleshooting + envoy_data = await envoy.update() + _LOGGER.debug("Envoy data: %s", envoy_data) + return envoy_data.raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate From 22c3847c0e5117c34e73b89659981c922b96d5cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 10:13:34 +0100 Subject: [PATCH 357/927] Allow inheriting `FrozenOrThawed` with custom init (#105624) --- homeassistant/util/frozen_dataclass_compat.py | 1 + tests/helpers/snapshots/test_entity.ambr | 18 ++++++++++++++++++ tests/helpers/test_entity.py | 12 ++++++++++++ 3 files changed, 31 insertions(+) diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 58faedeea6f..456fc4f1570 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -117,4 +117,5 @@ class FrozenOrThawed(type): return object.__new__(cls) return cls._dataclass(*_args, **kwargs) + cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] cls.__new__ = __new__ # type: ignore[method-assign] diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index 7f146fa0494..1031134d2ad 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -36,6 +36,24 @@ # name: test_extending_entity_description.1 "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- +# name: test_extending_entity_description.10 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.11 + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" +# --- # name: test_extending_entity_description.2 dict({ 'device_class': None, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 5a706b73b49..76577daf8a6 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1749,3 +1749,15 @@ def test_extending_entity_description(snapshot: SnapshotAssertion): key="blah", extra="foo", mixin="mixin", name="name" ) assert repr(obj) == snapshot + + # Try inheriting with custom init + @dataclasses.dataclass + class CustomInitEntityDescription(entity.EntityDescription): + def __init__(self, extra, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.extra: str = extra + + obj = CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert obj == snapshot + assert obj == CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert repr(obj) == snapshot From a91dfc79547e12c795efab1007305e1166f2c380 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 10:24:34 +0100 Subject: [PATCH 358/927] Fix entity descriptions in philips_js (#105625) --- homeassistant/components/philips_js/binary_sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 1e6c1241aea..ec93f0ab87e 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -18,14 +18,11 @@ from .const import DOMAIN from .entity import PhilipsJsEntity -@dataclass +@dataclass(kw_only=True) class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): """A entity description for Philips TV binary sensor.""" - def __init__(self, recording_value, *args, **kwargs) -> None: - """Set up a binary sensor entity description and add additional attributes.""" - super().__init__(*args, **kwargs) - self.recording_value: str = recording_value + recording_value: str DESCRIPTIONS = ( From 06f81251fbf29e53d3e26e2bf234d799816ac070 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 10:41:35 +0100 Subject: [PATCH 359/927] Reduce code duplication in Suez config flow (#105558) --- .../components/suez_water/config_flow.py | 71 +------------------ homeassistant/components/suez_water/sensor.py | 49 ++++++++++--- .../components/suez_water/test_config_flow.py | 18 +---- 3 files changed, 45 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index 1dd79c017e0..ba288c90e34 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -10,16 +10,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_COUNTER_ID, DOMAIN _LOGGER = logging.getLogger(__name__) -ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"} STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -81,80 +78,16 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import the yaml config.""" await self.async_set_unique_id(user_input[CONF_USERNAME]) - try: - self._abort_if_unique_id_configured() - except AbortFlow as err: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Suez Water", - }, - ) - raise err + self._abort_if_unique_id_configured() try: await self.hass.async_add_executor_job(validate_input, user_input) except CannotConnect: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_cannot_connect", - translation_placeholders=ISSUE_PLACEHOLDER, - ) return self.async_abort(reason="cannot_connect") except InvalidAuth: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_invalid_auth", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_invalid_auth", - translation_placeholders=ISSUE_PLACEHOLDER, - ) return self.async_abort(reason="invalid_auth") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_unknown", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_unknown", - translation_placeholders=ISSUE_PLACEHOLDER, - ) return self.async_abort(reason="unknown") - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Suez Water", - }, - ) return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 7d7540ed1c0..4602df27748 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -15,14 +15,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COUNTER_ID, DOMAIN _LOGGER = logging.getLogger(__name__) +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"} SCAN_INTERVAL = timedelta(hours=12) @@ -35,20 +38,48 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Suez Water", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, + ) async def async_setup_entry( diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 265598e5c64..c18b8a927e9 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -8,7 +8,6 @@ from homeassistant import config_entries from homeassistant.components.suez_water.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry @@ -138,9 +137,7 @@ async def test_form_error( assert len(mock_setup_entry.mock_calls) == 1 -async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry -) -> None: +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test import flow.""" with patch("homeassistant.components.suez_water.config_flow.SuezClient"): result = await hass.config_entries.flow.async_init( @@ -153,7 +150,6 @@ async def test_import( assert result["result"].unique_id == "test-username" assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 - assert len(issue_registry.issues) == 1 @pytest.mark.parametrize( @@ -164,7 +160,6 @@ async def test_import_error( mock_setup_entry: AsyncMock, exception: Exception, reason: str, - issue_registry: ir.IssueRegistry, ) -> None: """Test we handle errors while importing.""" @@ -178,12 +173,9 @@ async def test_import_error( assert result["type"] == FlowResultType.ABORT assert result["reason"] == reason - assert len(issue_registry.issues) == 1 -async def test_importing_invalid_auth( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: +async def test_importing_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth when importing.""" with patch( @@ -199,12 +191,9 @@ async def test_importing_invalid_auth( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_auth" - assert len(issue_registry.issues) == 1 -async def test_import_already_configured( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: +async def test_import_already_configured(hass: HomeAssistant) -> None: """Test we abort import when entry is already configured.""" entry = MockConfigEntry( @@ -220,4 +209,3 @@ async def test_import_already_configured( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - assert len(issue_registry.issues) == 1 From 22b2c588ebb5ef858aa100196fd8dc798abf2bfa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 11:23:38 +0100 Subject: [PATCH 360/927] Use issue registry fixture (#105633) --- tests/components/cloud/test_repairs.py | 12 +++++------- tests/components/harmony/test_switch.py | 2 +- tests/components/shelly/test_climate.py | 7 ++++--- tests/components/shelly/test_coordinator.py | 7 ++++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index 8d890a503e1..0e662c30ee7 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -21,10 +21,9 @@ from tests.typing import ClientSessionGenerator async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): await mock_cloud(hass) @@ -40,9 +39,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], + issue_registry: ir.IssueRegistry, ): """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": "legacy"}, @@ -61,9 +60,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( async def test_legacy_subscription_delete_issue_if_no_longer_legacy( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we delete the legacy subscription issue if no longer legacy.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) assert issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" @@ -80,9 +79,9 @@ async def test_legacy_subscription_repair_flow( aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, ): """Test desired flow of the fix flow for legacy subscription.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": None}, @@ -167,6 +166,7 @@ async def test_legacy_subscription_repair_flow_timeout( hass_client: ClientSessionGenerator, mock_auth: Generator[None, AsyncMock, None], aioclient_mock: AiohttpClientMocker, + issue_registry: ir.IssueRegistry, ): """Test timeout flow of the fix flow for legacy subscription.""" aioclient_mock.post( @@ -174,8 +174,6 @@ async def test_legacy_subscription_repair_flow_timeout( status=403, ) - issue_registry: ir.IssueRegistry = ir.async_get(hass) - cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) repair_issue = issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 59e5a7c7fc8..f843ab4deca 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -146,6 +146,7 @@ async def test_create_issue( hass: HomeAssistant, mock_write_config, entity_registry_enabled_by_default: None, + issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" assert await async_setup_component( @@ -186,7 +187,6 @@ async def test_create_issue( assert automations_with_entity(hass, ENTITY_WATCH_TV)[0] == "automation.test" assert scripts_with_entity(hass, ENTITY_WATCH_TV)[0] == "script.test" - issue_registry: ir.IssueRegistry = ir.async_get(hass) assert issue_registry.async_get_issue(DOMAIN, "deprecated_switches") assert issue_registry.async_get_issue( diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index fe518b8509c..f52b542b389 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -503,11 +503,12 @@ async def test_block_restored_climate_auth_error( async def test_device_not_calibrated( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> None: """Test to create an issue when the device is not calibrated.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index e73168c6b20..27aa8710621 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -250,11 +250,12 @@ async def test_block_sleeping_device_no_periodic_updates( async def test_block_device_push_updates_failure( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> None: """Test block device with push updates failure.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1) # Updates with COAP_REPLAY type should create an issue From 0548f9f3425e65449ff2d0eabe76d99b83bc715b Mon Sep 17 00:00:00 2001 From: mletenay Date: Wed, 13 Dec 2023 12:35:53 +0100 Subject: [PATCH 361/927] Add diagnostics download to goodwe integration (#102928) * Add diagnostics download to goodwe integration * Revert change not related to test * Use MagicMock for mock inverter * Use spec with mock --- .../components/goodwe/diagnostics.py | 35 +++++++++++++++++++ tests/components/goodwe/conftest.py | 25 +++++++++++++ .../goodwe/snapshots/test_diagnostics.ambr | 33 +++++++++++++++++ tests/components/goodwe/test_diagnostics.py | 34 ++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 homeassistant/components/goodwe/diagnostics.py create mode 100644 tests/components/goodwe/conftest.py create mode 100644 tests/components/goodwe/snapshots/test_diagnostics.ambr create mode 100644 tests/components/goodwe/test_diagnostics.py diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py new file mode 100644 index 00000000000..285036c0254 --- /dev/null +++ b/homeassistant/components/goodwe/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Goodwe.""" +from __future__ import annotations + +from typing import Any + +from goodwe import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, KEY_INVERTER + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] + + diagnostics_data = { + "config_entry": config_entry.as_dict(), + "inverter": { + "model_name": inverter.model_name, + "rated_power": inverter.rated_power, + "firmware": inverter.firmware, + "arm_firmware": inverter.arm_firmware, + "dsp1_version": inverter.dsp1_version, + "dsp2_version": inverter.dsp2_version, + "dsp_svn_version": inverter.dsp_svn_version, + "arm_version": inverter.arm_version, + "arm_svn_version": inverter.arm_svn_version, + }, + } + + return diagnostics_data diff --git a/tests/components/goodwe/conftest.py b/tests/components/goodwe/conftest.py new file mode 100644 index 00000000000..cabb0f6ea10 --- /dev/null +++ b/tests/components/goodwe/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for the Aladdin Connect integration tests.""" +from unittest.mock import AsyncMock, MagicMock + +from goodwe import Inverter +import pytest + + +@pytest.fixture(name="mock_inverter") +def fixture_mock_inverter(): + """Set up inverter fixture.""" + mock_inverter = MagicMock(spec=Inverter) + mock_inverter.serial_number = "dummy_serial_nr" + mock_inverter.arm_version = 1 + mock_inverter.arm_svn_version = 2 + mock_inverter.arm_firmware = "dummy.arm.version" + mock_inverter.firmware = "dummy.fw.version" + mock_inverter.model_name = "MOCK" + mock_inverter.rated_power = 10000 + mock_inverter.dsp1_version = 3 + mock_inverter.dsp2_version = 4 + mock_inverter.dsp_svn_version = 5 + + mock_inverter.read_runtime_data = AsyncMock(return_value={}) + + return mock_inverter diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f259e020cd5 --- /dev/null +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'model_family': 'ET', + }), + 'disabled_by': None, + 'domain': 'goodwe', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'inverter': dict({ + 'arm_firmware': 'dummy.arm.version', + 'arm_svn_version': 2, + 'arm_version': 1, + 'dsp1_version': 3, + 'dsp2_version': 4, + 'dsp_svn_version': 5, + 'firmware': 'dummy.fw.version', + 'model_name': 'MOCK', + 'rated_power': 10000, + }), + }) +# --- diff --git a/tests/components/goodwe/test_diagnostics.py b/tests/components/goodwe/test_diagnostics.py new file mode 100644 index 00000000000..edda2ed2cb7 --- /dev/null +++ b/tests/components/goodwe/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Test the CO2Signal diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +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, + snapshot: SnapshotAssertion, + mock_inverter: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "localhost", CONF_MODEL_FAMILY: "ET"}, + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + config_entry.add_to_hass(hass) + with patch("homeassistant.components.goodwe.connect", return_value=mock_inverter): + assert await async_setup_component(hass, DOMAIN, {}) + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot From 5bb233998e87ffa0acebdea0d39a6f8a715d614e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 13 Dec 2023 13:53:22 +0100 Subject: [PATCH 362/927] Improve cloud http api tests (#105610) * Improve cloud http api tests * Add comments to the cloud fixture * Fix docstring --- tests/components/cloud/conftest.py | 114 +++- tests/components/cloud/test_http_api.py | 726 ++++++++++++++++-------- 2 files changed, 599 insertions(+), 241 deletions(-) diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 221267c59fb..0de43c80e87 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,14 +1,124 @@ """Fixtures for cloud tests.""" -from unittest.mock import patch +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch +from hass_nabucasa import Cloud +from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.cloudhooks import Cloudhooks +from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.google_report_state import GoogleReportState +from hass_nabucasa.iot import CloudIoT +from hass_nabucasa.remote import RemoteUI +from hass_nabucasa.voice import Voice import jwt import pytest -from homeassistant.components.cloud import const, prefs +from homeassistant.components.cloud import CloudClient, const, prefs from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(name="cloud") +async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: + """Mock the cloud object. + + See the real hass_nabucasa.Cloud class for how to configure the mock. + """ + with patch( + "homeassistant.components.cloud.Cloud", autospec=True + ) as mock_cloud_class: + mock_cloud = mock_cloud_class.return_value + + # Attributes set in the constructor without parameters. + # We spec the mocks with the real classes + # and set constructor attributes or mock properties as needed. + mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) + mock_cloud.remote = MagicMock( + spec=RemoteUI, + certificate=None, + certificate_status=None, + instance_domain=None, + is_connected=False, + ) + mock_cloud.auth = MagicMock(spec=CognitoAuth) + mock_cloud.iot = MagicMock( + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + ) + mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.started = None + + def set_up_mock_cloud( + cloud_client: CloudClient, mode: str, **kwargs: Any + ) -> DEFAULT: + """Set up mock cloud with a mock constructor.""" + + # Attributes set in the constructor with parameters. + cloud_client.cloud = mock_cloud + mock_cloud.client = cloud_client + default_values = DEFAULT_VALUES[mode] + servers = { + f"{name}_server": server + for name, server in DEFAULT_SERVERS[mode].items() + } + mock_cloud.configure_mock(**default_values, **servers, **kwargs) + mock_cloud.mode = mode + + # Properties that we mock as attributes from the constructor. + mock_cloud.websession = cloud_client.websession + + return DEFAULT + + mock_cloud_class.side_effect = set_up_mock_cloud + + # Attributes that we mock with default values. + + mock_cloud.id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + mock_cloud.access_token = "test_access_token" + mock_cloud.refresh_token = "test_refresh_token" + + # Properties that we keep as properties. + + def mock_is_logged_in() -> bool: + """Mock is logged in.""" + return mock_cloud.id_token is not None + + is_logged_in = PropertyMock(side_effect=mock_is_logged_in) + type(mock_cloud).is_logged_in = is_logged_in + + def mock_claims() -> dict[str, Any]: + """Mock claims.""" + return Cloud._decode_claims(mock_cloud.id_token) + + claims = PropertyMock(side_effect=mock_claims) + type(mock_cloud).claims = claims + + # Properties that we mock as attributes. + mock_cloud.subscription_expired = False + + # Methods that we mock with a custom side effect. + + async def mock_login(email: str, password: str) -> None: + """Mock login. + + When called, it should call the on_start callback. + """ + on_start_callback = mock_cloud.register_on_start.call_args[0][0] + await on_start_callback() + + mock_cloud.login.side_effect = mock_login + + yield mock_cloud + + @pytest.fixture(autouse=True) def mock_tts_cache_dir_autouse(mock_tts_cache_dir): """Mock the TTS cache dir with empty dir.""" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index fc6861f2b49..15acc275931 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,19 +1,21 @@ """Tests for the HTTP API for the cloud component.""" import asyncio +from copy import deepcopy from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp +from aiohttp.test_utils import TestClient from hass_nabucasa import thingtalk, voice from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED -from jose import jwt import pytest from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities -from homeassistant.components.cloud.const import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities from homeassistant.core import HomeAssistant, State @@ -21,39 +23,67 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.location import LocationInfo -from . import mock_cloud, mock_cloud_prefs - from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator +PIPELINE_DATA_LEGACY = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + +PIPELINE_DATA_OTHER = { + "items": [ + { + "conversation_engine": "other", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant", + "stt_engine": "stt.other", + "stt_language": "language_1", + "tts_engine": "other", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info" -@pytest.fixture(name="mock_cloud_login") -def mock_cloud_login_fixture(hass, setup_api): - """Mock cloud is logged in.""" - hass.data[DOMAIN].id_token = jwt.encode( +@pytest.fixture(name="setup_cloud") +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, + DOMAIN, { - "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", - "cognito:username": "abcdefghjkl", - }, - "test", - ) - - -@pytest.fixture(autouse=True, name="setup_api") -def setup_api_fixture(hass, aioclient_mock): - """Initialize HTTP API.""" - hass.loop.run_until_complete( - mock_cloud( - hass, - { + DOMAIN: { "mode": "development", "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", + "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, @@ -61,27 +91,24 @@ def setup_api_fixture(hass, aioclient_mock): "filter": {"include_entities": ["light.kitchen", "switch.ac"]} }, }, - ) + }, ) - return mock_cloud_prefs(hass) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() @pytest.fixture(name="cloud_client") -def cloud_client_fixture(hass, hass_client): +async def cloud_client_fixture( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Fixture that can fetch from the cloud client.""" - with patch("hass_nabucasa.Cloud._write_user_info"): - yield hass.loop.run_until_complete(hass_client()) - - -@pytest.fixture(name="mock_cognito") -def mock_cognito_fixture(): - """Mock warrant.""" - with patch("hass_nabucasa.auth.CognitoAuth._cognito") as mock_cog: - yield mock_cog() + return await hass_client() async def test_google_actions_sync( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + cloud_client: TestClient, ) -> None: """Test syncing Google Actions.""" with patch( @@ -90,11 +117,12 @@ async def test_google_actions_sync( ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.OK - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 async def test_google_actions_sync_fails( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + cloud_client: TestClient, ) -> None: """Test syncing Google Actions gone bad.""" with patch( @@ -103,26 +131,32 @@ async def test_google_actions_sync_fails( ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 -async def test_login_view(hass: HomeAssistant, cloud_client) -> None: +@pytest.mark.parametrize("pipeline_data", [PIPELINE_DATA_LEGACY]) +async def test_login_view_existing_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + pipeline_data: dict[str, Any], +) -> None: """Test logging in when an assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(pipeline_data), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines", - return_value=[ - Mock( - conversation_engine="homeassistant", - id="12345", - stt_engine=DOMAIN, - tts_engine=DOMAIN, - ) - ], - ), patch( "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", ) as create_pipeline_mock: req = await cloud_client.post( @@ -135,11 +169,25 @@ async def test_login_view(hass: HomeAssistant, cloud_client) -> None: create_pipeline_mock.assert_not_awaited() -async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None: - """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) +async def test_login_view_create_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test logging in when no existing cloud assist pipeline is available.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", @@ -156,12 +204,24 @@ async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> async def test_login_view_create_pipeline_fail( - hass: HomeAssistant, cloud_client + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], ) -> None: """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", @@ -177,96 +237,143 @@ async def test_login_view_create_pipeline_fail( create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") -async def test_login_view_random_exception(cloud_client) -> None: - """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.Cloud.login", side_effect=ValueError("Boom")): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) +async def test_login_view_random_exception( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Try logging in with random exception.""" + cloud.login.side_effect = ValueError("Boom") + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + assert req.status == HTTPStatus.BAD_GATEWAY resp = await req.json() assert resp == {"code": "valueerror", "message": "Unexpected error: Boom"} -async def test_login_view_invalid_json(cloud_client) -> None: +async def test_login_view_invalid_json( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", data="Not JSON") + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", data="Not JSON") + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_invalid_schema(cloud_client) -> None: +async def test_login_view_invalid_schema( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Try logging in with invalid schema.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_request_timeout(cloud_client) -> None: +async def test_login_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test request timeout while trying to log in.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=asyncio.TimeoutError - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud.login.side_effect = asyncio.TimeoutError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_login_view_invalid_credentials(cloud_client) -> None: +async def test_login_view_invalid_credentials( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test logging in with invalid credentials.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=Unauthenticated - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud.login.side_effect = Unauthenticated + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.UNAUTHORIZED -async def test_login_view_unknown_error(cloud_client) -> None: +async def test_login_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test unknown error while logging in.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login", side_effect=UnknownError): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud.login.side_effect = UnknownError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test logging out.""" - cloud = hass.data["cloud"] = MagicMock() - cloud.logout = AsyncMock(return_value=None) req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.OK data = await req.json() assert data == {"message": "ok"} - assert len(cloud.logout.mock_calls) == 1 + assert cloud.logout.call_count == 1 -async def test_logout_view_request_timeout(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test timeout while logging out.""" - cloud = hass.data["cloud"] = MagicMock() cloud.logout.side_effect = asyncio.TimeoutError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view_unknown_error(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test unknown error while logging out.""" - cloud = hass.data["cloud"] = MagicMock() cloud.logout.side_effect = UnknownError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_no_location(mock_cognito, cloud_client) -> None: +async def test_register_view_no_location( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test register without location.""" + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=None, @@ -275,17 +382,23 @@ async def test_register_view_no_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" assert call.kwargs["client_metadata"] is None -async def test_register_view_with_location(mock_cognito, cloud_client) -> None: +async def test_register_view_with_location( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: """Test register with location.""" + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=LocationInfo( @@ -308,9 +421,10 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" @@ -321,124 +435,201 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: } -async def test_register_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_register_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test register bad data.""" + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "not_password": "falcon"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.logout.mock_calls) == 0 + assert mock_cognito.async_register.call_count == 0 -async def test_register_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.register.side_effect = asyncio.TimeoutError +async def test_register_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test timeout while registering.""" + cloud.auth.async_register.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.register.side_effect = UnknownError +async def test_register_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test unknown error while registering.""" + cloud.auth.async_register.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test forgot password.""" + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + assert mock_cognito.async_forgot_password.call_count == 1 -async def test_forgot_password_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test forgot password bad data.""" + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + assert mock_cognito.async_forgot_password.call_count == 0 -async def test_forgot_password_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError +async def test_forgot_password_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test timeout while forgot password.""" + cloud.auth.async_forgot_password.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = UnknownError +async def test_forgot_password_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test unknown error while forgot password.""" + cloud.auth.async_forgot_password.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_aiohttp_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = aiohttp.ClientResponseError( +async def test_forgot_password_view_aiohttp_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test unknown error while forgot password.""" + cloud.auth.async_forgot_password.side_effect = aiohttp.ClientResponseError( Mock(), Mock() ) + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_resend_confirm_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test resend confirm.""" + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 + assert mock_cognito.async_resend_email_confirm.call_count == 1 -async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test resend confirm bad data.""" + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 + assert mock_cognito.async_resend_email_confirm.call_count == 0 -async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = asyncio.TimeoutError +async def test_resend_confirm_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test timeout while resend confirm.""" + cloud.auth.async_resend_email_confirm.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = UnknownError +async def test_resend_confirm_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + cloud_client: TestClient, +) -> None: + """Test unknown error while resend confirm.""" + cloud.auth.async_resend_email_confirm.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY async def test_websocket_status( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_cloud_fixture, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test querying the status.""" - hass.data[DOMAIN].iot.state = STATE_CONNECTED + cloud.iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch.dict( @@ -452,6 +643,7 @@ async def test_websocket_status( ): await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": True, "email": "hello@home-assistant.io", @@ -462,8 +654,8 @@ async def test_websocket_status( "cloudhooks": {}, "google_enabled": True, "google_secure_devices_pin": None, - "google_default_expose": None, - "alexa_default_expose": None, + "google_default_expose": DEFAULT_EXPOSED_DOMAINS, + "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, "remote_enabled": False, @@ -493,17 +685,23 @@ async def test_websocket_status( "remote_certificate_status": None, "remote_certificate": None, "http_use_ssl": False, - "active_subscription": False, + "active_subscription": True, } async def test_websocket_status_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test querying the status not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": False, "cloud": "disconnected", @@ -515,30 +713,32 @@ async def test_websocket_subscription_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status and connecting because valid account.""" + """Test subscription info and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) client = await hass_ws_client(hass) + mock_renew = cloud.auth.async_renew_access_token + + await client.send_json({"id": 5, "type": "cloud/subscription"}) + response = await client.receive_json() - with patch("hass_nabucasa.auth.CognitoAuth.async_renew_access_token") as mock_renew: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 1 + assert mock_renew.call_count == 1 async def test_websocket_subscription_fail( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info fail.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=HTTPStatus.INTERNAL_SERVER_ERROR) client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() @@ -547,10 +747,15 @@ async def test_websocket_subscription_fail( async def test_websocket_subscription_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + with patch( "hass_nabucasa.cloud_api.async_subscription_info", return_value={"return": "value"}, @@ -565,15 +770,16 @@ async def test_websocket_subscription_not_logged_in( async def test_websocket_update_preferences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test updating preference.""" - assert setup_api.google_enabled - assert setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin is None + assert cloud.client.prefs.google_enabled + assert cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin is None + client = await hass_ws_client(hass) + await client.send_json( { "id": 5, @@ -587,18 +793,16 @@ async def test_websocket_update_preferences( response = await client.receive_json() assert response["success"] - assert not setup_api.google_enabled - assert not setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin == "1234" - assert setup_api.tts_default_voice == ("en-GB", "male") + assert not cloud.client.prefs.google_enabled + assert not cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin == "1234" + assert cloud.client.prefs.tts_default_voice == ("en-GB", "male") async def test_websocket_update_preferences_alexa_report_state( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating alexa_report_state sets alexa authorized.""" client = await hass_ws_client(hass) @@ -612,10 +816,12 @@ async def test_websocket_update_preferences_alexa_report_state( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(True) assert response["success"] @@ -624,9 +830,7 @@ async def test_websocket_update_preferences_alexa_report_state( async def test_websocket_update_preferences_require_relink( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference requires relink.""" client = await hass_ws_client(hass) @@ -641,10 +845,12 @@ async def test_websocket_update_preferences_require_relink( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -654,9 +860,7 @@ async def test_websocket_update_preferences_require_relink( async def test_websocket_update_preferences_no_token( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference no token available.""" client = await hass_ws_client(hass) @@ -671,10 +875,12 @@ async def test_websocket_update_preferences_no_token( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -682,69 +888,79 @@ async def test_websocket_update_preferences_no_token( async def test_enabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable webhooks.""" client = await hass_ws_client(hass) - with patch( - "hass_nabucasa.cloudhooks.Cloudhooks.async_create", return_value={} - ) as mock_enable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_enable = cloud.cloudhooks.async_create + mock_enable.return_value = {} - assert len(mock_enable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_enable.call_count == 1 assert mock_enable.mock_calls[0][1][0] == "mock-webhook-id" async def test_disabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to disable webhooks.""" client = await hass_ws_client(hass) - with patch("hass_nabucasa.cloudhooks.Cloudhooks.async_delete") as mock_disable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_disable = cloud.cloudhooks.async_delete - assert len(mock_disable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_disable.call_count == 1 assert mock_disable.mock_calls[0][1][0] == "mock-webhook-id" async def test_enabling_remote( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable remote UI.""" client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] - - with patch("hass_nabucasa.remote.RemoteUI.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - assert response["success"] - assert cloud.client.remote_autostart - - assert len(mock_connect.mock_calls) == 1 - - with patch("hass_nabucasa.remote.RemoteUI.disconnect") as mock_disconnect: - await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) - response = await client.receive_json() - assert response["success"] + mock_connect = cloud.remote.connect assert not cloud.client.remote_autostart - assert len(mock_disconnect.mock_calls) == 1 + await client.send_json({"id": 5, "type": "cloud/remote/connect"}) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.remote_autostart + assert mock_connect.call_count == 1 + + mock_disconnect = cloud.remote.disconnect + + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) + response = await client.receive_json() + + assert response["success"] + assert not cloud.client.remote_autostart + assert mock_disconnect.call_count == 1 async def test_list_google_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -762,6 +978,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -789,6 +1006,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -807,8 +1025,7 @@ async def test_get_google_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get a Google entity.""" client = await hass_ws_client(hass) @@ -818,6 +1035,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_found", @@ -829,10 +1047,12 @@ async def test_get_google_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -849,6 +1069,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -861,6 +1082,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -878,12 +1100,14 @@ async def test_get_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": True, @@ -895,13 +1119,12 @@ async def test_get_google_entity( async def test_update_google_entity( hass: HomeAssistant, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of a Google entity.""" client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "cloud/google_assistant/entities/update", @@ -910,6 +1133,7 @@ async def test_update_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( @@ -921,8 +1145,8 @@ async def test_update_google_entity( } ) response = await client.receive_json() - assert response["success"] + assert response["success"] assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == { "cloud.google_assistant": {"disable_2fa": False, "should_expose": False} } @@ -932,8 +1156,7 @@ async def test_list_alexa_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -946,6 +1169,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -965,6 +1189,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -978,8 +1203,7 @@ async def test_get_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get an Alexa entity.""" client = await hass_ws_client(hass) @@ -989,6 +1213,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -997,6 +1222,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1008,10 +1234,12 @@ async def test_get_alexa_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1029,6 +1257,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -1036,6 +1265,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1047,14 +1277,14 @@ async def test_update_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of an Alexa entity.""" entry = entity_registry.async_get_or_create( "light", "test", "unique", suggested_object_id="kitchen" ) client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "homeassistant/expose_entity", @@ -1072,10 +1302,13 @@ async def test_update_alexa_entity( async def test_sync_alexa_entities_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that timeout syncing Alexa entities.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1091,10 +1324,13 @@ async def test_sync_alexa_entities_timeout( async def test_sync_alexa_entities_no_token( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test sync Alexa entities when we have no token.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1110,10 +1346,13 @@ async def test_sync_alexa_entities_no_token( async def test_enable_alexa_state_report_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test enable Alexa entities state reporting when no token available.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1129,7 +1368,9 @@ async def test_enable_alexa_state_report_fail( async def test_thingtalk_convert( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1148,7 +1389,9 @@ async def test_thingtalk_convert( async def test_thingtalk_convert_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1167,7 +1410,9 @@ async def test_thingtalk_convert_timeout( async def test_thingtalk_convert_internal( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1187,7 +1432,9 @@ async def test_thingtalk_convert_internal( async def test_tts_info( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can get TTS info.""" # Verify the format is as expected @@ -1223,6 +1470,7 @@ async def test_tts_info( ) async def test_api_calls_require_admin( hass: HomeAssistant, + setup_cloud: None, hass_client: ClientSessionGenerator, hass_read_only_access_token: str, endpoint: str, From 02853a62f0822bb55c92d80c48ebe8c396a54f71 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 13 Dec 2023 14:21:33 +0100 Subject: [PATCH 363/927] Clean cloud client fixture from cloud http api tests (#105649) --- tests/components/cloud/test_http_api.py | 84 +++++++++++++++---------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 15acc275931..cc6fb4a1219 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -6,7 +6,6 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp -from aiohttp.test_utils import TestClient from hass_nabucasa import thingtalk, voice from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED @@ -98,19 +97,12 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: await on_start_callback() -@pytest.fixture(name="cloud_client") -async def cloud_client_fixture( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> TestClient: - """Fixture that can fetch from the cloud client.""" - return await hass_client() - - async def test_google_actions_sync( setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=200), @@ -122,9 +114,10 @@ async def test_google_actions_sync( async def test_google_actions_sync_fails( setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions gone bad.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), @@ -240,9 +233,10 @@ async def test_login_view_create_pipeline_fail( async def test_login_view_random_exception( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Try logging in with random exception.""" + cloud_client = await hass_client() cloud.login.side_effect = ValueError("Boom") req = await cloud_client.post( @@ -257,9 +251,10 @@ async def test_login_view_random_exception( async def test_login_view_invalid_json( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Try logging in with invalid JSON.""" + cloud_client = await hass_client() mock_login = cloud.login req = await cloud_client.post("/api/cloud/login", data="Not JSON") @@ -271,9 +266,10 @@ async def test_login_view_invalid_json( async def test_login_view_invalid_schema( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Try logging in with invalid schema.""" + cloud_client = await hass_client() mock_login = cloud.login req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) @@ -285,9 +281,10 @@ async def test_login_view_invalid_schema( async def test_login_view_request_timeout( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test request timeout while trying to log in.""" + cloud_client = await hass_client() cloud.login.side_effect = asyncio.TimeoutError req = await cloud_client.post( @@ -300,9 +297,10 @@ async def test_login_view_request_timeout( async def test_login_view_invalid_credentials( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test logging in with invalid credentials.""" + cloud_client = await hass_client() cloud.login.side_effect = Unauthenticated req = await cloud_client.post( @@ -315,9 +313,10 @@ async def test_login_view_invalid_credentials( async def test_login_view_unknown_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while logging in.""" + cloud_client = await hass_client() cloud.login.side_effect = UnknownError req = await cloud_client.post( @@ -330,9 +329,10 @@ async def test_login_view_unknown_error( async def test_logout_view( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test logging out.""" + cloud_client = await hass_client() req = await cloud_client.post("/api/cloud/logout") assert req.status == HTTPStatus.OK @@ -344,9 +344,10 @@ async def test_logout_view( async def test_logout_view_request_timeout( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test timeout while logging out.""" + cloud_client = await hass_client() cloud.logout.side_effect = asyncio.TimeoutError req = await cloud_client.post("/api/cloud/logout") @@ -357,9 +358,10 @@ async def test_logout_view_request_timeout( async def test_logout_view_unknown_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while logging out.""" + cloud_client = await hass_client() cloud.logout.side_effect = UnknownError req = await cloud_client.post("/api/cloud/logout") @@ -370,9 +372,10 @@ async def test_logout_view_unknown_error( async def test_register_view_no_location( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test register without location.""" + cloud_client = await hass_client() mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", @@ -395,9 +398,10 @@ async def test_register_view_no_location( async def test_register_view_with_location( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test register with location.""" + cloud_client = await hass_client() mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", @@ -438,9 +442,10 @@ async def test_register_view_with_location( async def test_register_view_bad_data( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test register bad data.""" + cloud_client = await hass_client() mock_cognito = cloud.auth req = await cloud_client.post( @@ -454,9 +459,10 @@ async def test_register_view_bad_data( async def test_register_view_request_timeout( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test timeout while registering.""" + cloud_client = await hass_client() cloud.auth.async_register.side_effect = asyncio.TimeoutError req = await cloud_client.post( @@ -469,9 +475,10 @@ async def test_register_view_request_timeout( async def test_register_view_unknown_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while registering.""" + cloud_client = await hass_client() cloud.auth.async_register.side_effect = UnknownError req = await cloud_client.post( @@ -484,9 +491,10 @@ async def test_register_view_unknown_error( async def test_forgot_password_view( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test forgot password.""" + cloud_client = await hass_client() mock_cognito = cloud.auth req = await cloud_client.post( @@ -500,9 +508,10 @@ async def test_forgot_password_view( async def test_forgot_password_view_bad_data( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test forgot password bad data.""" + cloud_client = await hass_client() mock_cognito = cloud.auth req = await cloud_client.post( @@ -516,9 +525,10 @@ async def test_forgot_password_view_bad_data( async def test_forgot_password_view_request_timeout( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test timeout while forgot password.""" + cloud_client = await hass_client() cloud.auth.async_forgot_password.side_effect = asyncio.TimeoutError req = await cloud_client.post( @@ -531,9 +541,10 @@ async def test_forgot_password_view_request_timeout( async def test_forgot_password_view_unknown_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while forgot password.""" + cloud_client = await hass_client() cloud.auth.async_forgot_password.side_effect = UnknownError req = await cloud_client.post( @@ -546,9 +557,10 @@ async def test_forgot_password_view_unknown_error( async def test_forgot_password_view_aiohttp_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while forgot password.""" + cloud_client = await hass_client() cloud.auth.async_forgot_password.side_effect = aiohttp.ClientResponseError( Mock(), Mock() ) @@ -563,9 +575,10 @@ async def test_forgot_password_view_aiohttp_error( async def test_resend_confirm_view( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test resend confirm.""" + cloud_client = await hass_client() mock_cognito = cloud.auth req = await cloud_client.post( @@ -579,9 +592,10 @@ async def test_resend_confirm_view( async def test_resend_confirm_view_bad_data( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test resend confirm bad data.""" + cloud_client = await hass_client() mock_cognito = cloud.auth req = await cloud_client.post( @@ -595,9 +609,10 @@ async def test_resend_confirm_view_bad_data( async def test_resend_confirm_view_request_timeout( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test timeout while resend confirm.""" + cloud_client = await hass_client() cloud.auth.async_resend_email_confirm.side_effect = asyncio.TimeoutError req = await cloud_client.post( @@ -610,9 +625,10 @@ async def test_resend_confirm_view_request_timeout( async def test_resend_confirm_view_unknown_error( cloud: MagicMock, setup_cloud: None, - cloud_client: TestClient, + hass_client: ClientSessionGenerator, ) -> None: """Test unknown error while resend confirm.""" + cloud_client = await hass_client() cloud.auth.async_resend_email_confirm.side_effect = UnknownError req = await cloud_client.post( From ac53b78a0c208feefc4626462ed132e6ef15897c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 14:21:44 +0100 Subject: [PATCH 364/927] Deduplicate constants A-D (#105638) --- homeassistant/components/airvisual/__init__.py | 2 +- homeassistant/components/airvisual/config_flow.py | 2 +- homeassistant/components/airvisual/const.py | 1 - homeassistant/components/airvisual/diagnostics.py | 3 ++- homeassistant/components/airvisual/sensor.py | 3 ++- homeassistant/components/amberelectric/const.py | 1 - homeassistant/components/blink/const.py | 1 - homeassistant/components/braviatv/config_flow.py | 3 +-- homeassistant/components/braviatv/const.py | 1 - homeassistant/components/braviatv/coordinator.py | 3 +-- homeassistant/components/elkm1/__init__.py | 2 +- homeassistant/components/elkm1/const.py | 1 - homeassistant/components/github/config_flow.py | 10 ++-------- homeassistant/components/github/const.py | 1 - homeassistant/components/github/diagnostics.py | 3 ++- homeassistant/components/homewizard/const.py | 1 - homeassistant/components/hue/bridge.py | 4 ++-- homeassistant/components/hue/config_flow.py | 3 +-- homeassistant/components/hue/const.py | 1 - homeassistant/components/hue/migration.py | 4 ++-- homeassistant/components/mysensors/config_flow.py | 2 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/device.py | 9 +++++++-- homeassistant/components/mysensors/gateway.py | 3 +-- homeassistant/components/prosegur/__init__.py | 4 ++-- homeassistant/components/prosegur/config_flow.py | 4 ++-- homeassistant/components/prosegur/const.py | 1 - homeassistant/components/samsungtv/bridge.py | 2 +- homeassistant/components/samsungtv/const.py | 1 - homeassistant/components/subaru/__init__.py | 9 +++++++-- homeassistant/components/subaru/config_flow.py | 10 ++++++++-- homeassistant/components/subaru/const.py | 1 - homeassistant/components/tplink/const.py | 1 - homeassistant/components/workday/__init__.py | 4 ++-- homeassistant/components/workday/binary_sensor.py | 3 +-- homeassistant/components/workday/config_flow.py | 3 +-- homeassistant/components/workday/const.py | 1 - homeassistant/components/xiaomi_miio/__init__.py | 3 +-- homeassistant/components/xiaomi_miio/air_quality.py | 3 +-- homeassistant/components/xiaomi_miio/binary_sensor.py | 3 +-- homeassistant/components/xiaomi_miio/config_flow.py | 3 +-- homeassistant/components/xiaomi_miio/const.py | 1 - homeassistant/components/xiaomi_miio/fan.py | 3 +-- homeassistant/components/xiaomi_miio/humidifier.py | 3 +-- homeassistant/components/xiaomi_miio/light.py | 9 +++++++-- homeassistant/components/xiaomi_miio/number.py | 2 +- homeassistant/components/xiaomi_miio/select.py | 3 +-- homeassistant/components/xiaomi_miio/sensor.py | 2 +- homeassistant/components/xiaomi_miio/switch.py | 2 +- homeassistant/components/xiaomi_miio/vacuum.py | 2 +- tests/components/braviatv/test_config_flow.py | 3 +-- tests/components/github/conftest.py | 7 ++----- tests/components/github/test_config_flow.py | 2 +- tests/components/mysensors/conftest.py | 2 +- tests/components/mysensors/test_config_flow.py | 2 +- tests/components/subaru/conftest.py | 9 +++++++-- tests/components/workday/test_config_flow.py | 3 +-- tests/components/xiaomi_miio/test_button.py | 2 +- tests/components/xiaomi_miio/test_config_flow.py | 10 +++++----- tests/components/xiaomi_miio/test_select.py | 2 +- tests/components/xiaomi_miio/test_vacuum.py | 2 +- 61 files changed, 91 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index e07400f2764..1d5babee6d7 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components import automation from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_IP_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE, @@ -44,7 +45,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_GEOGRAPHIES, CONF_INTEGRATION_TYPE, DOMAIN, diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 893726fc022..23a26e2cca6 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -35,7 +36,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( from . import async_get_geography_id from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_INTEGRATION_TYPE, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY_COORDS, diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py index 8e2c08eb896..0afa7d32d41 100644 --- a/homeassistant/components/airvisual/const.py +++ b/homeassistant/components/airvisual/const.py @@ -9,6 +9,5 @@ INTEGRATION_TYPE_GEOGRAPHY_NAME = "Geographical Location by Name" INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro" CONF_CITY = "city" -CONF_COUNTRY = "country" CONF_GEOGRAPHIES = "geographies" CONF_INTEGRATION_TYPE = "integration_type" diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index c273dbe7a55..05e716367bb 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE, @@ -15,7 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN CONF_COORDINATES = "coordinates" CONF_TITLE = "title" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1f0c5aa1baa..ab80e154903 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -25,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualEntity -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN ATTR_CITY = "city" ATTR_COUNTRY = "country" diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index f3cda887150..5f92e5a9117 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -4,7 +4,6 @@ import logging from homeassistant.const import Platform DOMAIN = "amberelectric" -CONF_API_TOKEN = "api_token" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" CONF_SITE_NMI = "site_nmi" diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 64b05e1ba27..d394b5c0008 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" -CONF_DEVICE_ID = "device_id" DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 3fb6e6b3b40..fd72203b249 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -22,7 +22,6 @@ from .const import ( ATTR_CID, ATTR_MAC, ATTR_MODEL, - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 34b621802f9..aff02aa9e8b 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -9,7 +9,6 @@ ATTR_MAC: Final = "macAddr" ATTR_MANUFACTURER: Final = "Sony" ATTR_MODEL: Final = "model" -CONF_CLIENT_ID: Final = "client_id" CONF_NICKNAME: Final = "nickname" CONF_USE_PSK: Final = "use_psk" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 20b30d1dd11..43f911cd3a2 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -19,14 +19,13 @@ from pybravia import ( ) from homeassistant.components.media_player import MediaType -from homeassistant.const import CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index b78157588e8..b633e1ae620 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, + CONF_ENABLED, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, @@ -46,7 +47,6 @@ from .const import ( CONF_AREA, CONF_AUTO_CONFIGURE, CONF_COUNTER, - CONF_ENABLED, CONF_KEYPAD, CONF_OUTPUT, CONF_PLC, diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py index a2bb5744c11..9e952c7ee0b 100644 --- a/homeassistant/components/elkm1/const.py +++ b/homeassistant/components/elkm1/const.py @@ -14,7 +14,6 @@ LOGIN_TIMEOUT = 20 CONF_AUTO_CONFIGURE = "auto_configure" CONF_AREA = "area" CONF_COUNTER = "counter" -CONF_ENABLED = "enabled" CONF_KEYPAD = "keypad" CONF_OUTPUT = "output" CONF_PLC = "plc" diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9afbf80297c..5e223483e2e 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -15,6 +15,7 @@ from aiogithubapi.const import OAUTH_USER_LOGIN import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import ( @@ -23,14 +24,7 @@ from homeassistant.helpers.aiohttp_client import ( ) import homeassistant.helpers.config_validation as cv -from .const import ( - CLIENT_ID, - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DEFAULT_REPOSITORIES, - DOMAIN, - LOGGER, -) +from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: diff --git a/homeassistant/components/github/const.py b/homeassistant/components/github/const.py index a186f4684b3..d01656ee8ae 100644 --- a/homeassistant/components/github/const.py +++ b/homeassistant/components/github/const.py @@ -13,7 +13,6 @@ CLIENT_ID = "1440cafcc86e3ea5d6a2" DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"] FALLBACK_UPDATE_INTERVAL = timedelta(hours=1, minutes=30) -CONF_ACCESS_TOKEN = "access_token" CONF_REPOSITORIES = "repositories" diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index c2546d636b8..15626497344 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -6,13 +6,14 @@ from typing import Any from aiogithubapi import GitHubAPI, GitHubException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -from .const import CONF_ACCESS_TOKEN, DOMAIN +from .const import DOMAIN from .coordinator import GitHubDataUpdateCoordinator diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index d4692ee8bf0..daeed9d3505 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -17,7 +17,6 @@ LOGGER = logging.getLogger(__package__) # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" -CONF_DEVICE = "device" CONF_PATH = "path" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 04bd63e5b1f..c5ceebec3f8 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -13,11 +13,11 @@ from aiohue.errors import AiohueException, BridgeBusy from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import aiohttp_client -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN from .v1.sensor_base import SensorManager from .v2.device import async_setup_devices from .v2.hue_event import async_setup_hue_events diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 0957329abb0..7262dea39ef 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -26,7 +26,6 @@ from homeassistant.helpers import ( from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, - CONF_API_VERSION, CONF_IGNORE_AVAILABILITY, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 38c2587bc1a..5033aaa427a 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -7,7 +7,6 @@ from aiohue.v2.models.relative_rotary import ( DOMAIN = "hue" -CONF_API_VERSION = "api_version" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_SUBTYPE = "subtype" diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 035da145cc0..f4bf6366d61 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -11,7 +11,7 @@ from homeassistant import core from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, CONF_USERNAME from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import ( async_entries_for_config_entry as devices_for_config_entries, @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_registry import ( async_get as async_get_entity_registry, ) -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index 8011bfcb155..fdf056c6c06 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.components.mqtt import ( valid_subscribe_topic, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -25,7 +26,6 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a5c82c32b55..0a4b4c090ef 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -11,7 +11,6 @@ ATTR_GATEWAY_ID: Final = "gateway_id" ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" -CONF_DEVICE: Final = "device" CONF_PERSISTENCE_FILE: Final = "persistence_file" CONF_RETAIN: Final = "retain" CONF_TCP_PORT: Final = "tcp_port" diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 6d7decf14f4..c70ef1f89ed 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -8,7 +8,13 @@ from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_DEVICE, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +23,6 @@ from homeassistant.helpers.entity import Entity from .const import ( CHILD_CALLBACK, - CONF_DEVICE, DOMAIN, NODE_CALLBACK, PLATFORM_TYPES, diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 590ad41d6a2..0818d68de2b 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -18,14 +18,13 @@ from homeassistant.components.mqtt import ( ReceivePayloadType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 9f594fc6dae..fd79a091e39 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -4,12 +4,12 @@ import logging from pyprosegur.auth import Auth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import CONF_COUNTRY, DOMAIN +from .const import DOMAIN PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ac2b704b012..c28245a09ff 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_CONTRACT, CONF_COUNTRY, DOMAIN +from .const import CONF_CONTRACT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index ea823e76062..495bec5d4ca 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -2,7 +2,6 @@ DOMAIN = "prosegur" -CONF_COUNTRY = "country" CONF_CONTRACT = "contract" SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 03a9c35c9ba..f2767ce693e 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -34,6 +34,7 @@ from samsungtvws.remote import ChannelEmitCommand, SendRemoteKey from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant.const import ( + CONF_DESCRIPTION, CONF_HOST, CONF_ID, CONF_METHOD, @@ -50,7 +51,6 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.util import dt as dt_util from .const import ( - CONF_DESCRIPTION, CONF_SESSION_ID, ENCRYPTED_WEBSOCKET_PORT, LEGACY_PORT, diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 6699d26243b..6c657145d7a 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -11,7 +11,6 @@ DEFAULT_MANUFACTURER = "Samsung" VALUE_CONF_NAME = "HomeAssistant" VALUE_CONF_ID = "ha.component.samsung" -CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 091a281defc..8a22391284f 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -6,7 +6,13 @@ import time from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -14,7 +20,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, COORDINATOR_NAME, DOMAIN, diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 6d1d5015ed3..b21feab7843 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -15,12 +15,18 @@ from subarulink.const import COUNTRY_CAN, COUNTRY_USA import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN +from .const import CONF_UPDATE_ENABLED, DOMAIN _LOGGER = logging.getLogger(__name__) CONF_CONTACT_METHOD = "contact_method" diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 9c94ed35361..ab76c363f7e 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -7,7 +7,6 @@ DOMAIN = "subaru" FETCH_INTERVAL = 300 UPDATE_INTERVAL = 7200 CONF_UPDATE_ENABLED = "update_enabled" -CONF_COUNTRY = "country" # entry fields ENTRY_CONTROLLER = "controller" diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index b1cd323a36a..22b5741fceb 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -13,7 +13,6 @@ ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DIMMER: Final = "dimmer" -CONF_DISCOVERY: Final = "discovery" CONF_LIGHT: Final = "light" CONF_STRIP: Final = "strip" CONF_SWITCH: Final = "switch" diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 455f5d4618a..3000570731b 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -4,12 +4,12 @@ from __future__ import annotations from holidays import HolidayBase, country_holidays, list_supported_countries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS +from .const import CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 2d1030c6b92..e2369baade5 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -26,7 +26,6 @@ from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 9ae31977276..859d3710ca4 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigFlow, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError @@ -33,7 +33,6 @@ from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 20905fb9892..ad9375830dd 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -12,7 +12,6 @@ ALLOWED_DAYS = WEEKDAYS + ["holiday"] DOMAIN = "workday" PLATFORMS = [Platform.BINARY_SENSOR] -CONF_COUNTRY = "country" CONF_PROVINCE = "province" CONF_WORKDAYS = "workdays" CONF_EXCLUDES = "excludes" diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 3c316fd3f47..716d4a04fa7 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -35,7 +35,7 @@ from miio import ( from miio.gateway.gateway import GatewayException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -43,7 +43,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( ATTR_AVAILABLE, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 30fcaa5152a..f9248ba5ff3 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -6,12 +6,11 @@ from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_CGDN1, diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 051ac2ab778..130b5ebd922 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -11,13 +11,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 70e6fb5c0b6..2a4deffb161 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -23,7 +23,6 @@ from .const import ( CONF_CLOUD_PASSWORD, CONF_CLOUD_SUBDEVICES, CONF_CLOUD_USERNAME, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, CONF_MAC, diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 6621e41e7aa..376c23c10d4 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -18,7 +18,6 @@ DOMAIN = "xiaomi_miio" # Config flow CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" -CONF_DEVICE = "device" CONF_MAC = "mac" CONF_CLOUD_USERNAME = "cloud_username" CONF_CLOUD_PASSWORD = "cloud_password" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 9be019ed724..30383426210 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -30,7 +30,7 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,7 +40,6 @@ from homeassistant.util.percentage import ( ) from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 0438b606efd..f2660bef68a 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -20,13 +20,12 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE, CONF_MODEL +from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 1fc032b5c36..8d198ae2a8f 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -33,7 +33,13 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE, + CONF_HOST, + CONF_MODEL, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +47,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color, dt as dt_util from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a8346caa894..1062b2d42b0 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -13,6 +13,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE, CONF_MODEL, DEGREE, REVOLUTIONS_PER_MINUTE, @@ -25,7 +26,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 74ce36ca57a..f6123ad0f0c 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -29,12 +29,11 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 17d60e1a952..200a67e5f54 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -48,7 +49,6 @@ from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 9bba9f61123..7de6192e736 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -21,6 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -31,7 +32,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 34a7b949646..73e2e54b62f 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -19,6 +19,7 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +28,6 @@ from homeassistant.util.dt import as_utc from . import VacuumCoordinatorData from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 1ac1fcd4bea..0f1d08792fa 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -12,14 +12,13 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, NICKNAME_PREFIX, ) from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py index 04b53da6b91..b0b6f243fa0 100644 --- a/tests/components/github/conftest.py +++ b/tests/components/github/conftest.py @@ -4,11 +4,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DOMAIN, -) +from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from .common import MOCK_ACCESS_TOKEN, TEST_REPOSITORY, setup_github_integration diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index ad3be582a5d..a86e1d134aa 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -6,11 +6,11 @@ from aiogithubapi import GitHubException from homeassistant import config_entries from homeassistant.components.github.config_flow import get_repositories from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, ) +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 64fbb61aac3..6df50f04ae2 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -16,12 +16,12 @@ from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_SERIAL, CONF_VERSION, DOMAIN, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index dc24a48edd4..bff13d1604f 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, @@ -23,6 +22,7 @@ from homeassistant.components.mysensors.const import ( DOMAIN, ConfGatewayType, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 8bed67cb15f..4927525d896 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -8,7 +8,6 @@ from subarulink.const import COUNTRY_USA from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.subaru.const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN, FETCH_INTERVAL, @@ -22,7 +21,13 @@ from homeassistant.components.subaru.const import ( VEHICLE_NAME, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 57a7046546e..fb0d78365e8 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.workday.const import ( CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_REMOVE_HOLIDAYS, @@ -19,7 +18,7 @@ from homeassistant.components.workday.const import ( DEFAULT_WORKDAYS, DOMAIN, ) -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.util.dt import UTC diff --git a/tests/components/xiaomi_miio/test_button.py b/tests/components/xiaomi_miio/test_button.py index 995c5ae034c..d00b2ec5853 100644 --- a/tests/components/xiaomi_miio/test_button.py +++ b/tests/components/xiaomi_miio/test_button.py @@ -5,7 +5,6 @@ import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_MAC, DOMAIN as XIAOMI_DOMAIN, @@ -13,6 +12,7 @@ from homeassistant.components.xiaomi_miio.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index a436908b44f..0fe8c3d247c 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -10,7 +10,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from . import TEST_MAC @@ -685,7 +685,7 @@ async def test_config_flow_step_device_manual_model_succes(hass: HomeAssistant) assert result["type"] == "create_entry" assert result["title"] == overwrite_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, @@ -729,7 +729,7 @@ async def config_flow_device_success(hass, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, @@ -775,7 +775,7 @@ async def config_flow_generic_roborock(hass): assert result["type"] == "create_entry" assert result["title"] == dummy_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, @@ -829,7 +829,7 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index 48b8216bffc..04cb6ee6ea7 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -17,7 +17,6 @@ from homeassistant.components.select import ( ) from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_MAC, DOMAIN as XIAOMI_DOMAIN, @@ -25,6 +24,7 @@ from homeassistant.components.xiaomi_miio.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 422a52b44ac..e1f2233c5bc 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -23,7 +23,6 @@ from homeassistant.components.vacuum import ( STATE_ERROR, ) from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_MAC, DOMAIN as XIAOMI_DOMAIN, @@ -32,6 +31,7 @@ from homeassistant.components.xiaomi_miio.const import ( from homeassistant.components.xiaomi_miio.vacuum import ( ATTR_ERROR, ATTR_TIMERS, + CONF_DEVICE, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, From 61a99c911c03c59a721206b9e1428ed200ba4f6d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Dec 2023 14:33:59 +0100 Subject: [PATCH 365/927] Migrate demo test to use freezegun (#105644) --- tests/components/demo/test_button.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py index bcaddab433b..6049de12570 100644 --- a/tests/components/demo/test_button.py +++ b/tests/components/demo/test_button.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS @@ -37,20 +38,20 @@ def test_setup_params(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN -async def test_press(hass: HomeAssistant) -> None: +async def test_press(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test pressing the button.""" state = hass.states.get(ENTITY_PUSH) assert state assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: ENTITY_PUSH}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_PUSH}, + blocking=True, + ) state = hass.states.get(ENTITY_PUSH) assert state From dff7725c1f76a786aa9f044d895da2414500c812 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 14:52:44 +0100 Subject: [PATCH 366/927] Fix goodwe tests (#105653) --- tests/components/goodwe/snapshots/test_diagnostics.ambr | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index f259e020cd5..4097848a34a 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'goodwe', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, From abac68f158ad488d358daeeb17afba12752df971 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 15:20:29 +0100 Subject: [PATCH 367/927] Avoid mutating entity descriptions in efergy (#105626) --- homeassistant/components/efergy/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6fc6eed40f6..809f1c531da 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,6 +1,7 @@ """Support for Efergy sensors.""" from __future__ import annotations +import dataclasses from re import sub from typing import cast @@ -121,7 +122,10 @@ async def async_setup_entry( ) ) else: - description.entity_registry_enabled_default = len(api.sids) > 1 + description = dataclasses.replace( + description, + entity_registry_enabled_default=len(api.sids) > 1, + ) for sid in api.sids: sensors.append( EfergySensor( From 7ab003c746c1a889f2072d71ad5e0b068a6c2fba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 15:22:29 +0100 Subject: [PATCH 368/927] Avoid mutating entity descriptions in lidarr (#105628) --- homeassistant/components/lidarr/sensor.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 1a2930c8051..552bc35768f 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from typing import Any, Generic from aiopyarr import LidarrQueue, LidarrQueueItem, LidarrRootFolder @@ -40,21 +39,23 @@ def get_modified_description( description: LidarrSensorEntityDescription[T], mount: LidarrRootFolder ) -> tuple[LidarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass class LidarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int] -@dataclass +@dataclasses.dataclass class LidarrSensorEntityDescription( SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] ): From 2d59eba4c74dad310e358d93719a551b8bb07b0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 15:23:38 +0100 Subject: [PATCH 369/927] Avoid mutating entity descriptions in airthings_ble (#105627) --- homeassistant/components/airthings_ble/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index aaeb91cf30b..c4797713bb8 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,6 +1,7 @@ """Support for airthings ble sensors.""" from __future__ import annotations +import dataclasses import logging from airthings_ble import AirthingsDevice @@ -167,10 +168,13 @@ async def async_setup_entry( # we need to change some units sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() if not is_metric: - for val in sensors_mapping.values(): + for key, val in sensors_mapping.items(): if val.native_unit_of_measurement is not VOLUME_BECQUEREL: continue - val.native_unit_of_measurement = VOLUME_PICOCURIE + sensors_mapping[key] = dataclasses.replace( + val, + native_unit_of_measurement=VOLUME_PICOCURIE, + ) entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) From e475829ce63d1a0c921c94c5e073dba411942d54 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 13 Dec 2023 10:24:26 -0500 Subject: [PATCH 370/927] Reload ZHA integration on any error, not just recoverable ones (#105659) --- homeassistant/components/zha/__init__.py | 75 +++++++++------------- homeassistant/components/zha/core/const.py | 3 - tests/components/zha/conftest.py | 2 +- tests/components/zha/test_repairs.py | 4 +- 4 files changed, 34 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 340e0db40a6..1eb3369c1be 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -37,8 +37,6 @@ from .core.const import ( DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -161,49 +159,40 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - # Retry setup a few times before giving up to deal with missing serial ports in VMs - for attempt in range(STARTUP_RETRIES): - try: - zha_gateway = await ZHAGateway.async_from_config( - hass=hass, - config=zha_data.yaml_config, - config_entry=config_entry, - ) - break - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise ConfigEntryError( - "Network settings do not match most recent backup" - ) from exc - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except Exception as exc: - _LOGGER.debug( - "Couldn't start coordinator (attempt %s of %s)", - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: + _LOGGER.debug("Failed to set up ZHA", exc_info=exc) + device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - if attempt < STARTUP_RETRIES - 1: - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - continue + if ( + not device_path.startswith("socket://") + and RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp + ): + try: + # Ignore all exceptions during probing, they shouldn't halt setup + if await warn_on_wrong_silabs_firmware(hass, device_path): + raise ConfigEntryError("Incorrect firmware installed") from exc + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - # Ignore all exceptions during probing, they shouldn't halt setup - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as ezsp_exc: - raise ConfigEntryNotReady from ezsp_exc - - raise + raise ConfigEntryNotReady from exc repairs.async_delete_blocking_issues(hass) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index f89ed8d9a52..ecbd347a621 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -409,9 +409,6 @@ class Strobe(t.enum8): Strobe = 0x01 -STARTUP_FAILURE_DELAY_S = 3 -STARTUP_RETRIES = 3 - EZSP_OVERWRITE_EUI64 = ( "i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it" ) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b3a536007a..55405d0a51c 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -46,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): + ): yield diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index d168e2e57b1..0efff5ecb52 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,7 +95,6 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER -@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -176,7 +175,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert config_entry.state == ConfigEntryState.SETUP_RETRY await hass.config_entries.async_unload(config_entry.entry_id) @@ -189,7 +188,6 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None -@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, From 816a37f9fc3b324fdb1edf7db9145a64a258afe6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 16:48:46 +0100 Subject: [PATCH 371/927] Fix timing issue in Withings (#105203) --- homeassistant/components/withings/__init__.py | 85 +++++++++++-------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 701f7f444cf..f42fb7a57b9 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -192,52 +192,67 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data + register_lock = asyncio.Lock() + webhooks_registered = False + async def unregister_webhook( _: Any, ) -> None: - LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) - webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(client) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(False) + nonlocal webhooks_registered + async with register_lock: + LOGGER.debug( + "Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await async_unsubscribe_webhooks(client) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(False) + webhooks_registered = False async def register_webhook( _: Any, ) -> None: - if cloud.async_active_subscription(hass): - webhook_url = await _async_cloudhook_generate_url(hass, entry) - else: - webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - url = URL(webhook_url) - if url.scheme != "https" or url.port != 443: - LOGGER.warning( - "Webhook not registered - " - "https and port 443 is required to register the webhook" + nonlocal webhooks_registered + async with register_lock: + if webhooks_registered: + return + if cloud.async_active_subscription(hass): + webhook_url = await _async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + url = URL(webhook_url) + if url.scheme != "https" or url.port != 443: + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name = f"{DEFAULT_TITLE} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(withings_data), + allowed_methods=[METH_POST], ) - return + LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url) - webhook_name = "Withings" - if entry.title != DEFAULT_TITLE: - webhook_name = f"{DEFAULT_TITLE} {entry.title}" - - webhook_register( - hass, - DOMAIN, - webhook_name, - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(withings_data), - allowed_methods=[METH_POST], - ) - - await async_subscribe_webhooks(client, webhook_url) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(True) - LOGGER.debug("Register Withings webhook: %s", webhook_url) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) + await async_subscribe_webhooks(client, webhook_url) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(True) + LOGGER.debug("Registered Withings webhook at Withings: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + webhooks_registered = True async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + LOGGER.debug("Cloudconnection state changed to %s", state) if state is cloud.CloudConnectionState.CLOUD_CONNECTED: await register_webhook(None) From e4453ace881b68fafc65c8adb6821ae265c73303 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 16:50:46 +0100 Subject: [PATCH 372/927] Add country code constant (#105640) --- homeassistant/components/buienradar/camera.py | 14 +++++--------- homeassistant/components/buienradar/config_flow.py | 7 ++++--- homeassistant/components/buienradar/const.py | 1 - homeassistant/components/co2signal/config_flow.py | 9 +++++++-- homeassistant/components/co2signal/const.py | 1 - homeassistant/components/co2signal/helpers.py | 4 +--- homeassistant/components/co2signal/util.py | 4 +--- homeassistant/components/picnic/__init__.py | 4 ++-- homeassistant/components/picnic/config_flow.py | 9 +++++++-- homeassistant/components/picnic/const.py | 1 - homeassistant/components/tuya/__init__.py | 2 +- homeassistant/components/tuya/config_flow.py | 2 +- homeassistant/components/tuya/const.py | 1 - homeassistant/components/tuya/diagnostics.py | 10 ++-------- homeassistant/const.py | 1 + tests/components/buienradar/test_camera.py | 6 +++--- tests/components/picnic/test_config_flow.py | 4 ++-- tests/components/picnic/test_sensor.py | 3 ++- tests/components/tuya/test_config_flow.py | 2 +- 19 files changed, 40 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 439921928d6..1963041bcca 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -10,19 +10,13 @@ import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - CONF_COUNTRY, - CONF_DELTA, - DEFAULT_COUNTRY, - DEFAULT_DELTA, - DEFAULT_DIMENSION, -) +from .const import CONF_DELTA, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION _LOGGER = logging.getLogger(__name__) @@ -40,7 +34,9 @@ async def async_setup_entry( config = entry.data options = entry.options - country = options.get(CONF_COUNTRY, config.get(CONF_COUNTRY, DEFAULT_COUNTRY)) + country = options.get( + CONF_COUNTRY_CODE, config.get(CONF_COUNTRY_CODE, DEFAULT_COUNTRY) + ) delta = options.get(CONF_DELTA, config.get(CONF_DELTA, DEFAULT_DELTA)) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 4a81a774b4f..1e77693f7fb 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -20,7 +20,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .const import ( - CONF_COUNTRY, CONF_DELTA, CONF_TIMEFRAME, DEFAULT_COUNTRY, @@ -32,7 +31,9 @@ from .const import ( OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): selector.CountrySelector( + vol.Optional( + CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY + ): selector.CountrySelector( selector.CountrySelectorConfig(countries=SUPPORTED_COUNTRY_CODES) ), vol.Optional(CONF_DELTA, default=DEFAULT_DELTA): selector.NumberSelector( diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 718812c5c73..c82970ed318 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -8,7 +8,6 @@ DEFAULT_DIMENSION = 700 DEFAULT_DELTA = 600 CONF_DELTA = "delta" -CONF_COUNTRY = "country_code" CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 234c1c01392..dfa1e25d7d8 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,7 +10,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_COUNTRY_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, +) from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -20,7 +25,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_COUNTRY_CODE, DOMAIN +from .const import DOMAIN from .helpers import fetch_latest_carbon_intensity from .util import get_extra_name diff --git a/homeassistant/components/co2signal/const.py b/homeassistant/components/co2signal/const.py index 1e0cbfe0f11..b025c655ce6 100644 --- a/homeassistant/components/co2signal/const.py +++ b/homeassistant/components/co2signal/const.py @@ -2,5 +2,4 @@ DOMAIN = "co2signal" -CONF_COUNTRY_CODE = "country_code" ATTRIBUTION = "Data provided by Electricity Maps" diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 43579c162e2..937b72a357c 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -5,11 +5,9 @@ from typing import Any from aioelectricitymaps import ElectricityMaps from aioelectricitymaps.models import CarbonIntensityResponse -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import CONF_COUNTRY_CODE - async def fetch_latest_carbon_intensity( hass: HomeAssistant, diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py index af0bec34904..68403b4803e 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -3,9 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE - -from .const import CONF_COUNTRY_CODE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE def get_extra_name(config: Mapping) -> str | None: diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index 6826d8940ab..d2f023af79f 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -3,10 +3,10 @@ from python_picnic_api import PicnicAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform from homeassistant.core import HomeAssistant -from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN +from .const import CONF_API, CONF_COORDINATOR, DOMAIN from .coordinator import PicnicUpdateCoordinator from .services import async_register_services diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 65ae201482a..b02c0a74bfc 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -12,10 +12,15 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.data_entry_flow import FlowResult -from .const import CONF_COUNTRY_CODE, COUNTRY_CODES, DOMAIN +from .const import COUNTRY_CODES, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 7e983321f3d..a2543c177e4 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -5,7 +5,6 @@ DOMAIN = "picnic" CONF_API = "api" CONF_COORDINATOR = "coordinator" -CONF_COUNTRY_CODE = "country_code" SERVICE_ADD_PRODUCT_TO_CART = "add_product" diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 276d21f3821..d0ae13c09b7 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -15,6 +15,7 @@ from tuya_iot import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -25,7 +26,6 @@ from .const import ( CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, CONF_USERNAME, diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index bf2c54a6158..eb490791f7e 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -7,13 +7,13 @@ from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY_CODE from .const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, CONF_USERNAME, diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 19faa76a191..56dbbc4fa40 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -38,7 +38,6 @@ CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" CONF_USERNAME = "username" CONF_PASSWORD = "password" -CONF_COUNTRY_CODE = "country_code" CONF_APP_TYPE = "tuya_app_type" TUYA_DISCOVERY_NEW = "tuya_discovery_new" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 454416970ea..adac97174b9 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -9,20 +9,14 @@ from tuya_iot import TuyaDevice from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.util import dt as dt_util from . import HomeAssistantTuyaData -from .const import ( - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, - CONF_ENDPOINT, - DOMAIN, - DPCode, -) +from .const import CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, DOMAIN, DPCode async def async_get_config_entry_diagnostics( diff --git a/homeassistant/const.py b/homeassistant/const.py index 8da1c251b4e..df68e3ab05a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -129,6 +129,7 @@ CONF_CONTINUE_ON_ERROR: Final = "continue_on_error" CONF_CONTINUE_ON_TIMEOUT: Final = "continue_on_timeout" CONF_COUNT: Final = "count" CONF_COUNTRY: Final = "country" +CONF_COUNTRY_CODE: Final = "country_code" CONF_COVERS: Final = "covers" CONF_CURRENCY: Final = "currency" CONF_CUSTOMIZE: Final = "customize" diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py index 027fde853c1..f048f8d69a7 100644 --- a/tests/components/buienradar/test_camera.py +++ b/tests/components/buienradar/test_camera.py @@ -6,8 +6,8 @@ from http import HTTPStatus from aiohttp.client_exceptions import ClientResponseError -from homeassistant.components.buienradar.const import CONF_COUNTRY, CONF_DELTA, DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.buienradar.const import CONF_DELTA, DOMAIN +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -144,7 +144,7 @@ async def test_belgium_country( aioclient_mock.get(radar_map_url(country_code="BE"), text="hello world") data = copy.deepcopy(TEST_CFG_DATA) - data[CONF_COUNTRY] = "BE" + data[CONF_COUNTRY_CODE] = "BE" mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=data) diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index a649240bd21..d90551b01df 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -6,8 +6,8 @@ from python_picnic_api.session import PicnicAuthError import requests from homeassistant import config_entries, data_entry_flow -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.picnic.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index cae10320fb9..fb1fbe9f009 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,11 +9,12 @@ import requests from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.components.picnic.const import DOMAIN from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, CURRENCY_EURO, STATE_UNAVAILABLE, STATE_UNKNOWN, diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 0630114da90..9505e1ef423 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -13,7 +13,6 @@ from homeassistant.components.tuya.const import ( CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, CONF_USERNAME, @@ -22,6 +21,7 @@ from homeassistant.components.tuya.const import ( TUYA_COUNTRIES, TUYA_SMART_APP, ) +from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 From bbfffbb47ec273351595c53c147c07981ad3a1ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 16:57:22 +0100 Subject: [PATCH 373/927] Avoid mutating entity descriptions in melcloud (#105629) --- homeassistant/components/melcloud/sensor.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index ca02d15db01..1cb8930049d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from typing import Any from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW @@ -23,7 +23,7 @@ from . import MelCloudDevice from .const import DOMAIN -@dataclass +@dataclasses.dataclass class MelcloudRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelcloudRequiredKeysMixin: enabled: Callable[[Any], bool] -@dataclass +@dataclasses.dataclass class MelcloudSensorEntityDescription( SensorEntityDescription, MelcloudRequiredKeysMixin ): @@ -203,7 +203,10 @@ class AtwZoneSensor(MelDeviceSensor): ) -> None: """Initialize the sensor.""" if zone.zone_index != 1: - description.key = f"{description.key}-zone-{zone.zone_index}" + description = dataclasses.replace( + description, + key=f"{description.key}-zone-{zone.zone_index}", + ) super().__init__(api, description) self._attr_device_info = api.zone_device_info(zone) From a82410d5e991a41661664c125b1d6a39a2393022 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Dec 2023 17:05:37 +0100 Subject: [PATCH 374/927] Deduplicate constants E-Z (#105657) --- .../components/airthings/__init__.py | 4 +-- .../components/airthings/config_flow.py | 3 ++- homeassistant/components/airthings/const.py | 1 - .../components/anthemav/config_flow.py | 10 ++----- homeassistant/components/anthemav/const.py | 2 +- .../components/anthemav/media_player.py | 4 +-- homeassistant/components/dsmr/config_flow.py | 3 +-- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 2 +- .../components/environment_canada/__init__.py | 4 +-- .../environment_canada/config_flow.py | 4 +-- .../components/environment_canada/const.py | 1 - .../components/eufylife_ble/__init__.py | 4 +-- .../components/eufylife_ble/config_flow.py | 4 +-- .../components/eufylife_ble/const.py | 2 -- .../components/flux_led/config_flow.py | 6 ++--- homeassistant/components/flux_led/const.py | 1 - homeassistant/components/flux_led/light.py | 2 +- .../components/frontier_silicon/__init__.py | 4 +-- .../frontier_silicon/config_flow.py | 3 +-- .../components/frontier_silicon/const.py | 1 - .../google_travel_time/config_flow.py | 3 +-- .../components/google_travel_time/const.py | 1 - .../components/homewizard/config_flow.py | 3 +-- homeassistant/components/homewizard/const.py | 1 - homeassistant/components/influxdb/__init__.py | 16 ++++++------ homeassistant/components/influxdb/const.py | 1 - homeassistant/components/influxdb/sensor.py | 2 +- homeassistant/components/knx/button.py | 10 ++----- homeassistant/components/knx/const.py | 1 - homeassistant/components/knx/schema.py | 2 +- homeassistant/components/knx/select.py | 2 +- homeassistant/components/lcn/const.py | 1 - homeassistant/components/lcn/helpers.py | 2 +- .../components/livisi/config_flow.py | 3 ++- homeassistant/components/livisi/const.py | 2 -- .../components/livisi/coordinator.py | 3 +-- homeassistant/components/nextbus/__init__.py | 4 +-- .../components/nextbus/config_flow.py | 4 +-- homeassistant/components/nextbus/const.py | 1 - homeassistant/components/nextbus/sensor.py | 4 +-- .../components/nextdns/config_flow.py | 4 +-- homeassistant/components/nextdns/const.py | 1 - .../components/openweathermap/__init__.py | 2 +- .../components/openweathermap/config_flow.py | 2 +- .../components/openweathermap/const.py | 1 - .../components/purpleair/__init__.py | 9 +++++-- .../components/purpleair/config_flow.py | 9 +++++-- homeassistant/components/purpleair/const.py | 1 - homeassistant/components/rachio/__init__.py | 4 +-- homeassistant/components/rachio/const.py | 1 - homeassistant/components/rachio/webhooks.py | 3 +-- .../components/reolink/config_flow.py | 10 +++++-- homeassistant/components/reolink/const.py | 1 - homeassistant/components/reolink/host.py | 10 +++++-- .../components/totalconnect/const.py | 1 - .../trafikverket_camera/__init__.py | 4 +-- .../trafikverket_camera/config_flow.py | 4 +-- .../components/trafikverket_camera/const.py | 1 - homeassistant/components/tuya/__init__.py | 4 +-- homeassistant/components/tuya/config_flow.py | 4 +-- homeassistant/components/tuya/const.py | 2 -- homeassistant/components/twinkly/__init__.py | 4 +-- .../components/twinkly/config_flow.py | 4 +-- homeassistant/components/twinkly/const.py | 5 ---- homeassistant/components/twinkly/light.py | 11 +++++--- .../components/watttime/config_flow.py | 2 +- homeassistant/components/watttime/const.py | 1 - homeassistant/components/watttime/sensor.py | 15 ++++++----- .../components/xiaomi_miio/config_flow.py | 3 +-- homeassistant/components/xiaomi_miio/const.py | 1 - .../components/xiaomi_miio/device.py | 4 +-- homeassistant/components/youtube/const.py | 1 - .../components/airthings/test_config_flow.py | 3 ++- tests/components/anthemav/conftest.py | 4 +-- .../environment_canada/test_config_flow.py | 8 ++---- .../environment_canada/test_diagnostics.py | 8 ++---- tests/components/flux_led/test_light.py | 2 +- tests/components/frontier_silicon/conftest.py | 7 ++--- .../google_travel_time/test_config_flow.py | 3 +-- tests/components/knx/test_button.py | 9 ++----- tests/components/knx/test_select.py | 3 +-- tests/components/livisi/__init__.py | 2 +- tests/components/nextbus/test_config_flow.py | 9 ++----- tests/components/nextbus/test_sensor.py | 9 ++----- tests/components/nextdns/test_config_flow.py | 8 ++---- .../openweathermap/test_config_flow.py | 2 +- tests/components/reolink/conftest.py | 10 +++++-- tests/components/reolink/test_config_flow.py | 26 ++++++++++++------- tests/components/reolink/test_media_source.py | 3 ++- .../trafikverket_camera/__init__.py | 3 +-- .../trafikverket_camera/test_config_flow.py | 4 +-- tests/components/tuya/test_config_flow.py | 4 +-- tests/components/twinkly/__init__.py | 2 +- tests/components/twinkly/test_config_flow.py | 9 ++----- tests/components/twinkly/test_init.py | 9 ++----- tests/components/twinkly/test_light.py | 9 ++----- tests/components/watttime/test_config_flow.py | 2 +- tests/components/xiaomi_miio/test_button.py | 2 +- .../xiaomi_miio/test_config_flow.py | 26 +++++++++---------- tests/components/xiaomi_miio/test_select.py | 2 +- tests/components/xiaomi_miio/test_vacuum.py | 2 +- 102 files changed, 193 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 423e890a855..d596c1db757 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -7,12 +7,12 @@ import logging from airthings import Airthings, AirthingsError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index f07f7164f2b..62f66213a0f 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -8,10 +8,11 @@ import airthings import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py index 70de549141b..5f846fbb31d 100644 --- a/homeassistant/components/airthings/const.py +++ b/homeassistant/components/airthings/const.py @@ -2,5 +2,4 @@ DOMAIN = "airthings" -CONF_ID = "id" CONF_SECRET = "secret" diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index e75c67cb2c5..892c40cde0e 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -10,18 +10,12 @@ from anthemav.device_error import DeviceError import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac -from .const import ( - CONF_MODEL, - DEFAULT_NAME, - DEFAULT_PORT, - DEVICE_TIMEOUT_SECONDS, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/anthemav/const.py b/homeassistant/components/anthemav/const.py index 2b1ff753fba..7cf586fb05d 100644 --- a/homeassistant/components/anthemav/const.py +++ b/homeassistant/components/anthemav/const.py @@ -1,6 +1,6 @@ """Constants for the Anthem A/V Receivers integration.""" ANTHEMAV_UPDATE_SIGNAL = "anthemav_update" -CONF_MODEL = "model" + DEFAULT_NAME = "Anthem AV" DEFAULT_PORT = 14999 DOMAIN = "anthemav" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 91f8536d348..c13e6389bfc 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,13 +13,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC +from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ANTHEMAV_UPDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER +from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 86a7bee9ef1..376b4d100fc 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -19,13 +19,12 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_DSMR_VERSION, - CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 4ac59372deb..9504929c5a9 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -11,7 +11,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" -CONF_PROTOCOL = "protocol" CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_SERIAL_ID = "serial_id" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 6aadcd63d44..9c511ef9191 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -28,6 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, + CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, EntityCategory, UnitOfEnergy, @@ -46,7 +47,6 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 64a4b7dad20..14fb3e8e54c 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -6,13 +6,13 @@ import xml.etree.ElementTree as et from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN +from .const import CONF_STATION, DOMAIN DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index 07b6eac0da0..f4b9ee792c3 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -7,10 +7,10 @@ from env_canada import ECWeather, ec_exc import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv -from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN +from .const import CONF_STATION, CONF_TITLE, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 16f7dc1cf99..f1f6db2e0df 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -2,7 +2,6 @@ ATTR_OBSERVATION_TIME = "observation_time" ATTR_STATION = "station" -CONF_LANGUAGE = "language" CONF_STATION = "station" CONF_TITLE = "title" DOMAIN = "environment_canada" diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index 49370c2efcf..f407e86a289 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,10 +6,10 @@ from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN from .models import EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/eufylife_ble/config_flow.py b/homeassistant/components/eufylife_ble/config_flow.py index 9e1ff4af7a8..e3a1a301f25 100644 --- a/homeassistant/components/eufylife_ble/config_flow.py +++ b/homeassistant/components/eufylife_ble/config_flow.py @@ -11,10 +11,10 @@ from homeassistant.components.bluetooth import ( async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, CONF_MODEL from homeassistant.data_entry_flow import FlowResult -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/eufylife_ble/const.py b/homeassistant/components/eufylife_ble/const.py index dac0afc9109..e6beb34aaff 100644 --- a/homeassistant/components/eufylife_ble/const.py +++ b/homeassistant/components/eufylife_ble/const.py @@ -1,5 +1,3 @@ """Constants for the EufyLife integration.""" DOMAIN = "eufylife_ble" - -CONF_MODEL = "model" diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 11e045bec70..9094006c791 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import contextlib -from typing import Any, Final, cast +from typing import Any, cast from flux_led.const import ( ATTR_ID, @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr @@ -47,8 +47,6 @@ from .discovery import ( ) from .util import format_as_flux_mac, mac_matches_by_one -CONF_DEVICE: Final = "device" - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Magic Home Integration.""" diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index db545aa1e68..8b42f5f2e0d 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -65,7 +65,6 @@ TRANSITION_STROBE: Final = "strobe" CONF_COLORS: Final = "colors" CONF_SPEED_PCT: Final = "speed_pct" CONF_TRANSITION: Final = "transition" -CONF_EFFECT: Final = "effect" EFFECT_SPEED_SUPPORT_MODES: Final = {ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW} diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index d880d517f1a..1232cb41031 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -22,6 +22,7 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) +from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv @@ -37,7 +38,6 @@ from .const import ( CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, DEFAULT_EFFECT_SPEED, diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 62f2623d05e..f1e0ad48d30 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -6,11 +6,11 @@ import logging from afsapi import AFSAPI, ConnectionError as FSConnectionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN +from .const import CONF_WEBFSAPI_URL, DOMAIN PLATFORMS = [Platform.MEDIA_PLAYER] diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 2274b1cdb44..470be7d9b26 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -16,11 +16,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( - CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 34201fe8f4a..94f4e09a35a 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -2,7 +2,6 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" -CONF_PIN = "pin" SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 83e144f6bbd..ec8187d91af 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ from .const import ( CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 0535e295b93..041858d948f 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -7,7 +7,6 @@ CONF_DESTINATION = "destination" CONF_OPTIONS = "options" CONF_ORIGIN = "origin" CONF_TRAVEL_MODE = "travel_mode" -CONF_LANGUAGE = "language" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index b24b49da965..bf425fe5c41 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -12,13 +12,12 @@ from voluptuous import Required, Schema from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from .const import ( CONF_API_ENABLED, - CONF_PATH, CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index daeed9d3505..f1a1bee2568 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -17,7 +17,6 @@ LOGGER = logging.getLogger(__package__) # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" -CONF_PATH = "path" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index f879ab37e8f..24c80dc1d54 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -22,9 +22,17 @@ import voluptuous as vol from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, + CONF_HOST, + CONF_PASSWORD, + CONF_PATH, + CONF_PORT, + CONF_SSL, CONF_TIMEOUT, + CONF_TOKEN, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, @@ -56,23 +64,15 @@ from .const import ( CONF_COMPONENT_CONFIG_GLOB, CONF_DB_NAME, CONF_DEFAULT_MEASUREMENT, - CONF_HOST, CONF_IGNORE_ATTRIBUTES, CONF_MEASUREMENT_ATTR, CONF_ORG, CONF_OVERRIDE_MEASUREMENT, - CONF_PASSWORD, - CONF_PATH, - CONF_PORT, CONF_PRECISION, CONF_RETRY_COUNT, - CONF_SSL, CONF_SSL_CA_CERT, CONF_TAGS, CONF_TAGS_ATTRIBUTES, - CONF_TOKEN, - CONF_USERNAME, - CONF_VERIFY_SSL, CONNECTION_ERROR, DEFAULT_API_VERSION, DEFAULT_HOST_V2, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index f3b0b66df54..5ffd70fe992 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -33,7 +33,6 @@ CONF_IGNORE_ATTRIBUTES = "ignore_attributes" CONF_PRECISION = "precision" CONF_SSL_CA_CERT = "ssl_ca_cert" -CONF_LANGUAGE = "language" CONF_QUERIES = "queries" CONF_QUERIES_FLUX = "queries_flux" CONF_GROUP_FUNCTION = "group_function" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index b4f643e876f..a46ec581207 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONF_API_VERSION, + CONF_LANGUAGE, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -35,7 +36,6 @@ from .const import ( CONF_FIELD, CONF_GROUP_FUNCTION, CONF_IMPORTS, - CONF_LANGUAGE, CONF_MEASUREMENT_NAME, CONF_QUERIES, CONF_QUERIES_FLUX, diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 274ced80146..94b5b51e401 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -6,18 +6,12 @@ from xknx.devices import RawValue as XknxRawValue from homeassistant import config_entries from homeassistant.components.button import ButtonEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DATA_KNX_CONFIG, - DOMAIN, - KNX_ADDRESS, -) +from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 519d5d0742d..3d1e3c62a34 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -68,7 +68,6 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" -CONF_PAYLOAD: Final = "payload" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 8240fbaf3c1..c7bcd90538f 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_EVENT, CONF_MODE, CONF_NAME, + CONF_PAYLOAD, CONF_TYPE, Platform, ) @@ -46,7 +47,6 @@ from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( CONF_INVERT, CONF_KNX_EXPOSE, - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 5baa068eaa6..2852917e021 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -9,6 +9,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, + CONF_PAYLOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, @@ -19,7 +20,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index bb97658b880..e8da5b39073 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -21,7 +21,6 @@ CONNECTION = "connection" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" -CONF_RESOURCE = "resource" CONF_DOMAIN_DATA = "domain_data" CONF_CONNECTIONS = "connections" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index e190b25eded..64a789f3a34 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_SENSORS, CONF_SOURCE, CONF_SWITCHES, @@ -42,7 +43,6 @@ from .const import ( CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_OUTPUT, - CONF_RESOURCE, CONF_SCENES, CONF_SK_NUM_TRIES, CONF_SOFTWARE_SERIAL, diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py index 16cccaacfd1..c8685eb2390 100644 --- a/homeassistant/components/livisi/config_flow.py +++ b/homeassistant/components/livisi/config_flow.py @@ -9,10 +9,11 @@ from aiolivisi import AioLivisi, errors as livisi_errors import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -from .const import CONF_HOST, CONF_PASSWORD, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER class LivisiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index f6435298f1e..2769e6030ee 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -5,8 +5,6 @@ from typing import Final LOGGER = logging.getLogger(__package__) DOMAIN = "livisi" -CONF_HOST = "host" -CONF_PASSWORD: Final = "password" AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 56e928307c1..17a3b1828d0 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -9,6 +9,7 @@ from aiolivisi import AioLivisi, LivisiEvent, Websocket from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,8 +18,6 @@ from .const import ( AVATAR, AVATAR_PORT, CLASSIC_PORT, - CONF_HOST, - CONF_PASSWORD, DEVICE_POLLING_DELAY, LIVISI_REACHABILITY_CHANGE, LIVISI_STATE_CHANGE, diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index e1f4dcc2840..e8c0bc224fe 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1,10 +1,10 @@ """NextBus platform.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_STOP, Platform from homeassistant.core import HomeAssistant -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index 84417a29c8d..a4045ada372 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -6,7 +6,7 @@ from py_nextbus import NextBusClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( SelectOptionDict, @@ -15,7 +15,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .util import listify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py index 9d9d0a5262f..0a2eabf57b3 100644 --- a/homeassistant/components/nextbus/const.py +++ b/homeassistant/components/nextbus/const.py @@ -3,4 +3,3 @@ DOMAIN = "nextbus" CONF_AGENCY = "agency" CONF_ROUTE = "route" -CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 6ef647f98ad..f62bf07eeef 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator from .util import listify, maybe_first diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 3985644a478..c502f788a86 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -9,11 +9,11 @@ from nextdns import ApiError, InvalidApiKeyError, NextDns import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PROFILE_ID, CONF_PROFILE_NAME, DOMAIN +from .const import CONF_PROFILE_ID, DOMAIN class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index 8cac556c87c..031dd1c5814 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -10,7 +10,6 @@ ATTR_SETTINGS = "settings" ATTR_STATUS = "status" CONF_PROFILE_ID = "profile_id" -CONF_PROFILE_NAME = "profile_name" UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index d462e34cd84..cfe28e2eacc 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -10,6 +10,7 @@ from pyowm.utils.config import get_default_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -18,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DOMAIN, ENTRY_NAME, diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index c418231946f..799be35fb42 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -17,7 +18,6 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 1420b1170ca..d7deab21743 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONF_LANGUAGE = "language" CONFIG_FLOW_VERSION = 2 ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 6b998f6879e..f52d0799d35 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -7,12 +7,17 @@ from typing import Any from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SHOW_ON_MAP, DOMAIN +from .const import DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3daa6f96fdf..e2b43726dc4 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -14,7 +14,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -35,7 +40,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.typing import EventType -from .const import CONF_SENSOR_INDICES, CONF_SHOW_ON_MAP, DOMAIN, LOGGER +from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index e3ea7807a21..60f51a9e7dd 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -7,4 +7,3 @@ LOGGER = logging.getLogger(__package__) CONF_READ_KEY = "read_key" CONF_SENSOR_INDICES = "sensor_indices" -CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index 8f9c9395ade..e47004f5fb7 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -7,12 +7,12 @@ from requests.exceptions import ConnectTimeout from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, CONF_WEBHOOK_ID, DOMAIN +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 92a57505a7c..dad044e5049 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -65,7 +65,6 @@ SIGNAL_RACHIO_RAIN_SENSOR_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_rain_sensor" SIGNAL_RACHIO_ZONE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_zone" SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule" -CONF_WEBHOOK_ID = "webhook_id" CONF_CLOUDHOOK_URL = "cloudhook_url" # Webhook callbacks diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 5c2fbe5965f..298b9c03701 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -5,13 +5,12 @@ from aiohttp import web from homeassistant.components import cloud, webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import URL_API +from homeassistant.const import CONF_WEBHOOK_ID, URL_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_CLOUDHOOK_URL, - CONF_WEBHOOK_ID, DOMAIN, KEY_EXTERNAL_ID, KEY_TYPE, diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index a27c84b9593..fc9b717f89b 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -10,13 +10,19 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost from .util import is_connected diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 2a35a0f723d..8aa01bfac41 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,3 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" -CONF_PROTOCOL = "protocol" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index dfc77806932..77aeffd5412 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -13,7 +13,13 @@ from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac @@ -21,7 +27,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 30 diff --git a/homeassistant/components/totalconnect/const.py b/homeassistant/components/totalconnect/const.py index 5012a303b69..1e98adaaa70 100644 --- a/homeassistant/components/totalconnect/const.py +++ b/homeassistant/components/totalconnect/const.py @@ -2,7 +2,6 @@ DOMAIN = "totalconnect" CONF_USERCODES = "usercodes" -CONF_LOCATION = "location" AUTO_BYPASS = "auto_bypass_low_battery" # Most TotalConnect alarms will work passing '-1' as usercode diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 3ac3ce35882..f0f758272f7 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,12 +6,12 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_LOCATION, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index 7572855b7d4..104a6a470dc 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,12 +14,12 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION 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 +from .const import DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index ff40d1bbc91..728ba9f7bd5 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -2,7 +2,6 @@ from homeassistant.const import Platform DOMAIN = "trafikverket_camera" -CONF_LOCATION = "location" PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index d0ae13c09b7..ee084b77ef1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -15,7 +15,7 @@ from tuya_iot import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY_CODE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -27,8 +27,6 @@ from .const import ( CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, PLATFORMS, diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index eb490791f7e..f933ac84519 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -7,7 +7,7 @@ from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_COUNTRY_CODE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from .const import ( CONF_ACCESS_ID, @@ -15,8 +15,6 @@ from .const import ( CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, SMARTLIFE_APP, diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 56dbbc4fa40..4cdca8f3904 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -36,8 +36,6 @@ CONF_PROJECT_TYPE = "tuya_project_type" CONF_ENDPOINT = "endpoint" CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" CONF_APP_TYPE = "tuya_app_type" TUYA_DISCOVERY_NEW = "tuya_discovery_new" diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index 897bfaf4e20..d57a56f489b 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from aiohttp import ClientError from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, Platform +from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index eab44dba591..e37e0fd6170 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -11,10 +11,10 @@ from voluptuous import Required, Schema from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_NAME, DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN +from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py index 2158e4aae07..f33024ed156 100644 --- a/homeassistant/components/twinkly/const.py +++ b/homeassistant/components/twinkly/const.py @@ -2,11 +2,6 @@ DOMAIN = "twinkly" -# Keys of the config entry -CONF_ID = "id" -CONF_HOST = "host" -CONF_NAME = "name" - # Strongly named HA attributes keys ATTR_HOST = "host" ATTR_VERSION = "version" diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 6d0b31b06ed..c4301936088 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -19,16 +19,19 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL +from homeassistant.const import ( + ATTR_SW_VERSION, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, DATA_CLIENT, DATA_DEVICE_INFO, DEV_LED_PROFILE, diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 4f4206da6ec..12601c0af83 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import callback @@ -23,7 +24,6 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, LOGGER, ) diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py index 5bb8cb50d40..ce2731e7832 100644 --- a/homeassistant/components/watttime/const.py +++ b/homeassistant/components/watttime/const.py @@ -7,4 +7,3 @@ LOGGER = logging.getLogger(__package__) CONF_BALANCING_AUTHORITY = "balancing_authority" CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" -CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 2a0e21ecf4c..ca5b0d06fa2 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -10,7 +10,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, PERCENTAGE, UnitOfMass +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + PERCENTAGE, + UnitOfMass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,12 +26,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ( - CONF_BALANCING_AUTHORITY, - CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, - DOMAIN, -) +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN ATTR_BALANCING_AUTHORITY = "balancing_authority" diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 2a4deffb161..02e88c6b14e 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -25,7 +25,6 @@ from .const import ( CONF_CLOUD_USERNAME, CONF_FLOW_TYPE, CONF_GATEWAY, - CONF_MAC, CONF_MANUAL, DEFAULT_CLOUD_COUNTRY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 376c23c10d4..ef9668dbee4 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -18,7 +18,6 @@ DOMAIN = "xiaomi_miio" # Config flow CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" -CONF_MAC = "mac" CONF_CLOUD_USERNAME = "cloud_username" CONF_CLOUD_PASSWORD = "cloud_password" CONF_CLOUD_COUNTRY = "cloud_country" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index da860c7045e..0c87f74a7e6 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -8,7 +8,7 @@ from typing import Any, TypeVar from construct.core import ChecksumError from miio import Device, DeviceException -from homeassistant.const import ATTR_CONNECTIONS, CONF_MODEL +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -17,7 +17,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_MAC, DOMAIN, AuthException, SetupException +from .const import DOMAIN, AuthException, SetupException _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index 7404cd04665..63c4480c007 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -7,7 +7,6 @@ MANUFACTURER = "Google, Inc." CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" CONF_CHANNELS = "channels" -CONF_ID = "id" CONF_UPLOAD_PLAYLIST = "upload_playlist_id" COORDINATOR = "coordinator" AUTH = "auth" diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 3a0c852535e..3228b3c7229 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -4,7 +4,8 @@ from unittest.mock import patch import airthings from homeassistant import config_entries -from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 7797f08872f..4c1abdd3c9b 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.components.anthemav.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index e3058697f3e..b745ac02693 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -6,12 +6,8 @@ import aiohttp import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 3eedb7a0ddb..fb1597e3622 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -5,12 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from syrupy import SnapshotAssertion -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 974a029d143..6ddb9e1687f 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -23,7 +23,6 @@ from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, CONF_WHITE_CHANNEL_TYPE, @@ -55,6 +54,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_EFFECT, CONF_HOST, CONF_MODE, CONF_NAME, diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 40a6df85310..1def9b160b2 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -4,11 +4,8 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.frontier_silicon.const import ( - CONF_PIN, - CONF_WEBFSAPI_URL, - DOMAIN, -) +from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.const import CONF_PIN from tests.common import MockConfigEntry diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 15132baf25a..9e575389e72 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -8,7 +8,6 @@ from homeassistant.components.google_travel_time.const import ( CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, @@ -21,7 +20,7 @@ from homeassistant.components.google_travel_time.const import ( DOMAIN, UNITS_IMPERIAL, ) -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index a905e66fe5d..3dedea7d8d4 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -4,14 +4,9 @@ import logging import pytest -from homeassistant.components.knx.const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DOMAIN, - KNX_ADDRESS, -) +from homeassistant.components.knx.const import CONF_PAYLOAD_LENGTH, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index 1c89338920e..f113a83f7a0 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -2,7 +2,6 @@ import pytest from homeassistant.components.knx.const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -10,7 +9,7 @@ from homeassistant.components.knx.const import ( KNX_ADDRESS, ) from homeassistant.components.knx.schema import SelectSchema -from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from .conftest import KNXTestKit diff --git a/tests/components/livisi/__init__.py b/tests/components/livisi/__init__.py index 3d28d1db708..48a7e21ad8d 100644 --- a/tests/components/livisi/__init__.py +++ b/tests/components/livisi/__init__.py @@ -1,7 +1,7 @@ """Tests for the LIVISI Smart Home integration.""" from unittest.mock import patch -from homeassistant.components.livisi.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD VALID_CONFIG = { CONF_HOST: "1.1.1.1", diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py index 9f427757183..0b67f817eb2 100644 --- a/tests/components/nextbus/test_config_flow.py +++ b/tests/components/nextbus/test_config_flow.py @@ -5,13 +5,8 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, setup -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) -from homeassistant.const import CONF_NAME +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index a4d04997e15..92da27783bc 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -8,15 +8,10 @@ from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop import pytest from homeassistant.components import sensor -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.update_coordinator import UpdateFailed diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index b5d718b61aa..a27898629ad 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -6,13 +6,9 @@ from nextdns import ApiError, InvalidApiKeyError import pytest from homeassistant import data_entry_flow -from homeassistant.components.nextdns.const import ( - CONF_PROFILE_ID, - CONF_PROFILE_NAME, - DOMAIN, -) +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant from . import PROFILES, init_integration diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 2bd62936fe5..87f76817044 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -5,7 +5,6 @@ from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant import data_entry_flow from homeassistant.components.openweathermap.const import ( - CONF_LANGUAGE, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DOMAIN, @@ -13,6 +12,7 @@ from homeassistant.components.openweathermap.const import ( from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 464d4120c65..3f81a30f898 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -6,7 +6,13 @@ import pytest from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac @@ -121,7 +127,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 9b449d4b851..dd9949a5dce 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -14,7 +14,13 @@ from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.util.dt import utcnow @@ -68,7 +74,7 @@ async def test_config_flow_manual_success( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -195,7 +201,7 @@ async def test_config_flow_errors( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -212,7 +218,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: "rtsp", + CONF_PROTOCOL: "rtsp", }, title=TEST_NVR_NAME, ) @@ -228,12 +234,12 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={const.CONF_PROTOCOL: "rtmp"}, + user_input={CONF_PROTOCOL: "rtmp"}, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { - const.CONF_PROTOCOL: "rtmp", + CONF_PROTOCOL: "rtmp", } @@ -252,7 +258,7 @@ async def test_change_connection_settings( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -295,7 +301,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -376,7 +382,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -435,7 +441,7 @@ async def test_dhcp_ip_update( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 7fe3570564a..ddb66463419 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, CONF_USERNAME, Platform, ) @@ -271,7 +272,7 @@ async def test_browsing_not_loaded( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME2, ) diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index a9aa3ad70d1..dd23c7bce7e 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -1,8 +1,7 @@ """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, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION ENTRY_CONFIG = { CONF_API_KEY: "1234567890", diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 305066832e5..ca1d8554c4a 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -13,8 +13,8 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries -from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 9505e1ef423..f8345683d4a 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -14,14 +14,12 @@ from homeassistant.components.tuya.const import ( CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, SMARTLIFE_APP, TUYA_COUNTRIES, TUYA_SMART_APP, ) -from homeassistant.const import CONF_COUNTRY_CODE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index bd51ac5d7cd..4b1411e9223 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,4 +1,4 @@ -"""Constants and mock for the twkinly component tests.""" +"""Constants and mock for the twinkly component tests.""" from aiohttp.client_exceptions import ClientConnectionError diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2d335c69923..a65a2a2d963 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -3,13 +3,8 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_MODEL, TEST_NAME, ClientMock diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index f2049f9b513..33f24a31d8f 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -3,14 +3,9 @@ from unittest.mock import patch from uuid import uuid4 -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index bcb40f22d08..e3b8b499c8e 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -4,13 +4,8 @@ from __future__ import annotations from unittest.mock import patch from homeassistant.components.light import ATTR_BRIGHTNESS, LightEntityFeature -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py index dbe1e5444d7..ce9284924f5 100644 --- a/tests/components/watttime/test_config_flow.py +++ b/tests/components/watttime/test_config_flow.py @@ -12,13 +12,13 @@ from homeassistant.components.watttime.config_flow import ( from homeassistant.components.watttime.const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, ) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import HomeAssistant diff --git a/tests/components/xiaomi_miio/test_button.py b/tests/components/xiaomi_miio/test_button.py index d00b2ec5853..552b302aafe 100644 --- a/tests/components/xiaomi_miio/test_button.py +++ b/tests/components/xiaomi_miio/test_button.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) @@ -14,6 +13,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 0fe8c3d247c..b36924764fe 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -10,7 +10,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from . import TEST_MAC @@ -172,7 +172,7 @@ async def test_config_flow_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -205,7 +205,7 @@ async def test_config_flow_gateway_cloud_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -251,7 +251,7 @@ async def test_config_flow_gateway_cloud_multiple_success(hass: HomeAssistant) - CONF_HOST: TEST_HOST2, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC2, + CONF_MAC: TEST_MAC2, } @@ -460,7 +460,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -692,7 +692,7 @@ async def test_config_flow_step_device_manual_model_succes(hass: HomeAssistant) CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: overwrite_model, - const.CONF_MAC: None, + CONF_MAC: None, } @@ -736,7 +736,7 @@ async def config_flow_device_success(hass, model_to_test): CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -782,7 +782,7 @@ async def config_flow_generic_roborock(hass): CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: dummy_model, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -836,7 +836,7 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -879,7 +879,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -919,7 +919,7 @@ async def test_options_flow_incomplete(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -957,7 +957,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -1005,5 +1005,5 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index 04cb6ee6ea7..a999f0e7c9a 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -18,7 +18,6 @@ from homeassistant.components.select import ( from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODEL_AIRFRESH_T2017, ) @@ -26,6 +25,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index e1f2233c5bc..9e823035dd9 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -24,7 +24,6 @@ from homeassistant.components.vacuum import ( ) from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) @@ -44,6 +43,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, STATE_UNAVAILABLE, From 4e9b9add29d6936b3f056e3f0830090a65472703 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:06:46 -0500 Subject: [PATCH 375/927] Bump ZHA dependencies (#105661) --- homeassistant/components/zha/core/const.py | 1 - homeassistant/components/zha/core/gateway.py | 10 ----- homeassistant/components/zha/manifest.json | 8 ++-- homeassistant/components/zha/radio_manager.py | 2 - requirements_all.txt | 8 ++-- requirements_test_all.txt | 8 ++-- tests/components/zha/test_gateway.py | 45 +------------------ 7 files changed, 13 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecbd347a621..7e591a596e5 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,7 +139,6 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" -CONF_USE_THREAD = "use_thread" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5c038a2d7f8..6c461ac45c3 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,7 +46,6 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_RADIO_TYPE, - CONF_USE_THREAD, CONF_ZIGPY, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, @@ -158,15 +157,6 @@ class ZHAGateway: if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True - # The bellows UART thread sometimes propagates a cancellation into the main Core - # event loop, when a connection to a TCP coordinator fails in a specific way - if ( - CONF_USE_THREAD not in app_config - and radio_type is RadioType.ezsp - and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") - ): - app_config[CONF_USE_THREAD] = False - # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4c8a58a12cf..fe58ff044cd 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.1", + "bellows==0.37.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.107", - "zigpy-deconz==0.22.0", - "zigpy==0.60.0", - "zigpy-xbee==0.20.0", + "zigpy-deconz==0.22.2", + "zigpy==0.60.1", + "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.0", "universal-silabs-flasher==0.0.15", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d3ca03de8d8..92a90e0e13a 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -10,7 +10,6 @@ import logging import os from typing import Any, Self -from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -175,7 +174,6 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False - app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( diff --git a/requirements_all.txt b/requirements_all.txt index 2fdbe18fa27..2b937eb1ac3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.1 +bellows==0.37.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2847,10 +2847,10 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.0 +zigpy-deconz==0.22.2 # homeassistant.components.zha -zigpy-xbee==0.20.0 +zigpy-xbee==0.20.1 # homeassistant.components.zha zigpy-zigate==0.12.0 @@ -2859,7 +2859,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.60.0 +zigpy==0.60.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 148d0597d8a..f706adb50cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.1 +bellows==0.37.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2139,10 +2139,10 @@ zeversolar==0.3.1 zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.22.0 +zigpy-deconz==0.22.2 # homeassistant.components.zha -zigpy-xbee==0.20.0 +zigpy-xbee==0.20.1 # homeassistant.components.zha zigpy-zigate==0.12.0 @@ -2151,7 +2151,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.60.0 +zigpy==0.60.1 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 4f520920704..1d9042daa4a 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,9 +1,8 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting @@ -223,48 +222,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@pytest.mark.parametrize( - ("device_path", "thread_state", "config_override"), - [ - ("/dev/ttyUSB0", True, {}), - ("socket://192.168.1.123:9999", False, {}), - ("socket://192.168.1.123:9999", True, {"use_thread": True}), - ], -) -async def test_gateway_initialize_bellows_thread( - device_path: str, - thread_state: bool, - config_override: dict, - hass: HomeAssistant, - zigpy_app_controller: ControllerApplication, - config_entry: MockConfigEntry, -) -> None: - """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - config_entry.data = dict(config_entry.data) - config_entry.data["device"]["path"] = device_path - config_entry.add_to_hass(hass) - - zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) as mock_new: - await zha_gateway.async_initialize() - - mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state - - await zha_gateway.shutdown() - - @pytest.mark.parametrize( ("device_path", "config_override", "expected_channel"), [ From 4f9f54892923f558f3d495216ad50be912beddf2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 17:26:34 +0100 Subject: [PATCH 376/927] Add volume_step property to MediaPlayerEntity (#105574) * Add volume_step property to MediaPlayerEntity * Improve tests * Address review comments --- .../components/media_player/__init__.py | 22 ++++++++- .../media_player/test_async_helpers.py | 45 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 50365f90f1f..2ca47b97275 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -454,6 +454,7 @@ class MediaPlayerEntityDescription(EntityDescription): """A class that describes media player entities.""" device_class: MediaPlayerDeviceClass | None = None + volume_step: float | None = None class MediaPlayerEntity(Entity): @@ -505,6 +506,7 @@ class MediaPlayerEntity(Entity): _attr_state: MediaPlayerState | None = None _attr_supported_features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0) _attr_volume_level: float | None = None + _attr_volume_step: float # Implement these for your media player @property @@ -533,6 +535,18 @@ class MediaPlayerEntity(Entity): """Volume level of the media player (0..1).""" return self._attr_volume_level + @property + def volume_step(self) -> float: + """Return the step to be used by the volume_up and volume_down services.""" + if hasattr(self, "_attr_volume_step"): + return self._attr_volume_step + if ( + hasattr(self, "entity_description") + and (volume_step := self.entity_description.volume_step) is not None + ): + return volume_step + return 0.1 + @property def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" @@ -956,7 +970,9 @@ class MediaPlayerEntity(Entity): and self.volume_level < 1 and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET ): - await self.async_set_volume_level(min(1, self.volume_level + 0.1)) + await self.async_set_volume_level( + min(1, self.volume_level + self.volume_step) + ) async def async_volume_down(self) -> None: """Turn volume down for media player. @@ -972,7 +988,9 @@ class MediaPlayerEntity(Entity): and self.volume_level > 0 and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET ): - await self.async_set_volume_level(max(0, self.volume_level - 0.1)) + await self.async_set_volume_level( + max(0, self.volume_level - self.volume_step) + ) async def async_media_play_pause(self) -> None: """Play or pause the media player.""" diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index cf71b52c046..a24c9cc76b2 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -10,6 +10,7 @@ from homeassistant.const import ( STATE_PLAYING, STATE_STANDBY, ) +from homeassistant.core import HomeAssistant class ExtendedMediaPlayer(mp.MediaPlayerEntity): @@ -148,28 +149,64 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): self._state = STATE_STANDBY +class AttrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via _attr_*.""" + + _attr_volume_step = 0.2 + + +class DescrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via entity description.""" + + entity_description = mp.MediaPlayerEntityDescription(key="test", volume_step=0.3) + + @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) def player(hass, request): """Return a media player.""" return request.param(hass) -async def test_volume_up(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_up( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_up and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_up() - assert player.volume_level == 0.6 + assert player.volume_level == 0.5 + volume_step -async def test_volume_down(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_down( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_down and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_down() - assert player.volume_level == 0.4 + assert player.volume_level == 0.5 - volume_step async def test_media_play_pause(player) -> None: From dd5a48996ae621754064c90956ffb60739c1f302 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 17:27:26 +0100 Subject: [PATCH 377/927] Keep capabilities up to date in the entity registry (#101748) * Keep capabilities up to date in the entity registry * Warn if entities update their capabilities very often * Fix updating of device class * Stop tracking capability updates once flooding is logged * Only sync registry if state changed * Improve test * Revert "Only sync registry if state changed" This reverts commit 1c52571596c06444df234d4b088242b494b630f2. * Avoid calculating device class twice * Address review comments * Revert using dataclass * Fix unintended revert * Add helper method --- homeassistant/components/group/__init__.py | 3 +- .../components/group/media_player.py | 3 +- .../components/template/template_entity.py | 9 +- homeassistant/helpers/entity.py | 97 ++++++++++- tests/helpers/test_entity.py | 160 +++++++++++++++++- 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ae246041db9..a2a61b3016a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -509,7 +509,8 @@ class GroupEntity(Entity): self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index bc238519cfa..b85fbf32a0d 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -236,7 +236,8 @@ class MediaPlayerGroup(MediaPlayerEntity): ) -> None: """Handle child updates.""" self.async_update_group_state() - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 8c3554c067e..f9c61850e58 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -430,14 +430,17 @@ class TemplateEntity(Entity): return try: - state, attrs = self._async_generate_attributes() - validate_state(state) + calculated_state = self._async_calculate_state() + validate_state(calculated_state.state) except Exception as err: # pylint: disable=broad-exception-caught self._preview_callback(None, None, None, str(err)) else: assert self._template_result_info self._preview_callback( - state, attrs, self._template_result_info.listeners, None + calculated_state.state, + calculated_state.attributes, + self._template_result_info.listeners, + None, ) @callback diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index dad0e2e00f3..cc709f4c754 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC import asyncio +from collections import deque from collections.abc import Coroutine, Iterable, Mapping, MutableMapping import dataclasses from datetime import timedelta @@ -75,6 +76,9 @@ DATA_ENTITY_SOURCE = "entity_info" # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 +# How many times per hour we allow capabilities to be updated before logging a warning +CAPABILITIES_UPDATE_LIMIT = 100 + @callback def async_setup(hass: HomeAssistant) -> None: @@ -237,6 +241,22 @@ class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): unit_of_measurement: str | None = None +@dataclasses.dataclass(frozen=True, slots=True) +class CalculatedState: + """Container with state and attributes. + + Returned by Entity._async_calculate_state. + """ + + state: str + # The union of all attributes, after overriding with entity registry settings + attributes: dict[str, Any] + # Capability attributes returned by the capability_attributes property + capability_attributes: Mapping[str, Any] | None + # Attributes which may be overridden by the entity registry + shadowed_attributes: Mapping[str, Any] + + class Entity(ABC): """An abstract class for Home Assistant entities.""" @@ -311,6 +331,8 @@ class Entity(ABC): # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] + __capabilities_updated_at: deque[float] + __capabilities_updated_at_reported: bool = False __remove_event: asyncio.Event | None = None # Entity Properties @@ -775,12 +797,29 @@ class Entity(ABC): return f"{device_name} {name}" if device_name else name @callback - def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + def _async_calculate_state(self) -> CalculatedState: """Calculate state string and attribute mapping.""" + return CalculatedState(*self.__async_calculate_state()) + + def __async_calculate_state( + self, + ) -> tuple[str, dict[str, Any], Mapping[str, Any] | None, Mapping[str, Any]]: + """Calculate state string and attribute mapping. + + Returns a tuple (state, attr, capability_attr, shadowed_attr). + state - the stringified state + attr - the attribute dictionary + capability_attr - a mapping with capability attributes + shadowed_attr - a mapping with attributes which may be overridden + + This method is called when writing the state to avoid the overhead of creating + a dataclass object. + """ entry = self.registry_entry - attr = self.capability_attributes - attr = dict(attr) if attr else {} + capability_attr = self.capability_attributes + attr = dict(capability_attr) if capability_attr else {} + shadowed_attr = {} available = self.available # only call self.available once per update cycle state = self._stringify_state(available) @@ -797,26 +836,30 @@ class Entity(ABC): if (attribution := self.attribution) is not None: attr[ATTR_ATTRIBUTION] = attribution + shadowed_attr[ATTR_DEVICE_CLASS] = self.device_class if ( - device_class := (entry and entry.device_class) or self.device_class + device_class := (entry and entry.device_class) + or shadowed_attr[ATTR_DEVICE_CLASS] ) is not None: attr[ATTR_DEVICE_CLASS] = str(device_class) if (entity_picture := self.entity_picture) is not None: attr[ATTR_ENTITY_PICTURE] = entity_picture - if (icon := (entry and entry.icon) or self.icon) is not None: + shadowed_attr[ATTR_ICON] = self.icon + if (icon := (entry and entry.icon) or shadowed_attr[ATTR_ICON]) is not None: attr[ATTR_ICON] = icon + shadowed_attr[ATTR_FRIENDLY_NAME] = self._friendly_name_internal() if ( - name := (entry and entry.name) or self._friendly_name_internal() + name := (entry and entry.name) or shadowed_attr[ATTR_FRIENDLY_NAME] ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features - return (state, attr) + return (state, attr, capability_attr, shadowed_attr) @callback def _async_write_ha_state(self) -> None: @@ -842,9 +885,45 @@ class Entity(ABC): return start = timer() - state, attr = self._async_generate_attributes() + state, attr, capabilities, shadowed_attr = self.__async_calculate_state() end = timer() + if entry: + # Make sure capabilities in the entity registry are up to date. Capabilities + # include capability attributes, device class and supported features + original_device_class: str | None = shadowed_attr[ATTR_DEVICE_CLASS] + supported_features: int = attr.get(ATTR_SUPPORTED_FEATURES) or 0 + if ( + capabilities != entry.capabilities + or original_device_class != entry.original_device_class + or supported_features != entry.supported_features + ): + if not self.__capabilities_updated_at_reported: + time_now = hass.loop.time() + capabilities_updated_at = self.__capabilities_updated_at + capabilities_updated_at.append(time_now) + while time_now - capabilities_updated_at[0] > 3600: + capabilities_updated_at.popleft() + if len(capabilities_updated_at) > CAPABILITIES_UPDATE_LIMIT: + self.__capabilities_updated_at_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is updating its capabilities too often," + " please %s" + ), + entity_id, + type(self), + report_issue, + ) + entity_registry = er.async_get(self.hass) + self.registry_entry = entity_registry.async_update_entity( + self.entity_id, + capabilities=capabilities, + original_device_class=original_device_class, + supported_features=supported_features, + ) + if end - start > 0.4 and not self._slow_reported: self._slow_reported = True report_issue = self._suggest_report_issue() @@ -1118,6 +1197,8 @@ class Entity(ABC): ) self._async_subscribe_device_updates() + self.__capabilities_updated_at = deque(maxlen=CAPABILITIES_UPDATE_LIMIT + 1) + async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 76577daf8a6..e9d0906970a 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -8,6 +8,7 @@ import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -1412,8 +1413,8 @@ async def test_repr_using_stringify_state() -> None: """Return the state.""" raise ValueError("Boom") - entity = MyEntity(entity_id="test.test", available=False) - assert str(entity) == "" + my_entity = MyEntity(entity_id="test.test", available=False) + assert str(my_entity) == "" async def test_warn_using_async_update_ha_state( @@ -1761,3 +1762,158 @@ def test_extending_entity_description(snapshot: SnapshotAssertion): assert obj == snapshot assert obj == CustomInitEntityDescription(key="blah", extra="foo", name="name") assert repr(obj) == snapshot + + +async def test_update_capabilities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == 127 + + ent._values["capability_attributes"] = None + ent._values["device_class"] = None + ent._values["supported_features"] = None + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # Device class can be overridden by user, make sure that does not break the + # automatic updating. + entity_registry.async_update_entity(ent.entity_id, device_class="set_by_user") + await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # This will not trigger a state change because the device class is shadowed + # by the entity registry + ent._values["device_class"] = "some_class" + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class == "some_class" + assert entry.supported_features == 0 + + +async def test_update_capabilities_no_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity() + await platform.async_add_entities([ent]) + + assert entity_registry.async_get(ent.entity_id) is None + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + assert entity_registry.async_get(ent.entity_id) is None + + +async def test_update_capabilities_too_often( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning in caplog.text + + +async def test_update_capabilities_too_often_cooldown( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + freezer.tick(timedelta(minutes=60) + timedelta(seconds=1)) + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning not in caplog.text From 08ca3678daafaa60bc127c21d9a33df53ddc286d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 13 Dec 2023 18:07:29 +0100 Subject: [PATCH 378/927] Ensure platform setup for all AVM FRITZ!SmartHome devices (#105515) --- .../components/fritzbox/binary_sensor.py | 10 ++++++---- homeassistant/components/fritzbox/button.py | 12 ++++++------ homeassistant/components/fritzbox/climate.py | 10 ++++++---- homeassistant/components/fritzbox/cover.py | 10 ++++++---- homeassistant/components/fritzbox/light.py | 17 ++++++++--------- homeassistant/components/fritzbox/sensor.py | 10 ++++++---- homeassistant/components/fritzbox/switch.py | 10 ++++++---- 7 files changed, 44 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 2460635351e..e766a53518a 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -70,20 +70,22 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( FritzboxBinarySensor(coordinator, ain, description) - for ain in coordinator.new_devices + for ain in devices for description in BINARY_SENSOR_TYPES if description.suitable(coordinator.data.devices[ain]) ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index 732c41bfb7d..e56ebc1e3b0 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -19,17 +19,17 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(templates: set[str] | None = None) -> None: """Add templates.""" - if not coordinator.new_templates: + if templates is None: + templates = coordinator.new_templates + if not templates: return - async_add_entities( - FritzBoxTemplate(coordinator, ain) for ain in coordinator.new_templates - ) + async_add_entities(FritzBoxTemplate(coordinator, ain) for ain in templates) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.templates.keys())) class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 70359d9b2af..6ce885a3fdb 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -52,19 +52,21 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( FritzboxThermostat(coordinator, ain) - for ain in coordinator.new_devices + for ain in devices if coordinator.data.devices[ain].has_thermostat ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index 7d27356fdf9..d3d4c9080ea 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -24,19 +24,21 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( FritzboxCover(coordinator, ain) - for ain in coordinator.new_devices + for ain in devices if coordinator.data.devices[ain].has_blind ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 8dc51e59738..88d32fe33a5 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -30,22 +30,21 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( - FritzboxLight( - coordinator, - ain, - ) - for ain in coordinator.new_devices - if (coordinator.data.devices[ain]).has_lightbulb + FritzboxLight(coordinator, ain) + for ain in devices + if coordinator.data.devices[ain].has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzboxLight(FritzBoxDeviceEntity, LightEntity): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 1e5d7754934..fda8b239859 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -215,20 +215,22 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( FritzBoxSensor(coordinator, ain, description) - for ain in coordinator.new_devices + for ain in devices for description in SENSOR_TYPES if description.suitable(coordinator.data.devices[ain]) ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 617a5242c5b..4a2960a18ea 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -19,19 +19,21 @@ async def async_setup_entry( coordinator = get_coordinator(hass, entry.entry_id) @callback - def _add_entities() -> None: + def _add_entities(devices: set[str] | None = None) -> None: """Add devices.""" - if not coordinator.new_devices: + if devices is None: + devices = coordinator.new_devices + if not devices: return async_add_entities( FritzboxSwitch(coordinator, ain) - for ain in coordinator.new_devices + for ain in devices if coordinator.data.devices[ain].has_switch ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities() + _add_entities(set(coordinator.data.devices.keys())) class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): From d322cb5fdffdaffe7585e93f93f433d2785fca4d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Dec 2023 19:37:51 +0100 Subject: [PATCH 379/927] Migrate homekit_controller tests to use freezegun (#105646) --- .../components/homekit_controller/conftest.py | 11 ++- .../specific_devices/test_koogeek_ls1.py | 2 +- .../test_alarm_control_panel.py | 6 +- .../homekit_controller/test_binary_sensor.py | 14 ++-- .../homekit_controller/test_button.py | 2 +- .../homekit_controller/test_camera.py | 6 +- .../homekit_controller/test_climate.py | 78 +++++++------------ .../homekit_controller/test_cover.py | 30 ++++--- .../homekit_controller/test_device_trigger.py | 5 -- .../homekit_controller/test_diagnostics.py | 3 +- .../homekit_controller/test_event.py | 10 +-- .../components/homekit_controller/test_fan.py | 44 +++++------ .../homekit_controller/test_humidifier.py | 26 +++---- .../homekit_controller/test_init.py | 2 +- .../homekit_controller/test_light.py | 28 +++---- .../homekit_controller/test_lock.py | 6 +- .../homekit_controller/test_media_player.py | 20 ++--- .../homekit_controller/test_number.py | 6 +- .../homekit_controller/test_select.py | 10 +-- .../homekit_controller/test_sensor.py | 24 +++--- .../homekit_controller/test_storage.py | 2 +- .../homekit_controller/test_switch.py | 14 ++-- 22 files changed, 152 insertions(+), 197 deletions(-) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 043213ec159..904b752205e 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -1,9 +1,9 @@ """HomeKit controller session fixtures.""" import datetime -from unittest import mock import unittest.mock from aiohomekit.testing import FakeController +from freezegun import freeze_time import pytest import homeassistant.util.dt as dt_util @@ -13,14 +13,13 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 pytest.register_assert_rewrite("tests.components.homekit_controller.common") -@pytest.fixture -def utcnow(request): +@pytest.fixture(autouse=True) +def freeze_time_in_future(request): """Freeze time at a known point.""" now = dt_util.utcnow() start_dt = datetime.datetime(now.year + 1, 1, 1, 0, 0, 0, tzinfo=now.tzinfo) - with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - yield dt_utcnow + with freeze_time(start_dt) as frozen_time: + yield frozen_time @pytest.fixture diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 2c2c0b5e1c5..baee3082106 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -21,7 +21,7 @@ LIGHT_ON = ("lightbulb", "on") @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) -async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: +async def test_recover_from_failure(hass: HomeAssistant, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. See https://github.com/home-assistant/core/issues/18949 diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index c38c3d47bfe..19991d7cc13 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -26,7 +26,7 @@ def create_security_system_service(accessory): targ_state.value = 50 -async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_security_system_service) @@ -83,7 +83,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_alarm_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_security_system_service) @@ -125,7 +125,7 @@ async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a alarm_control_panel unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 382d6182733..92c303cab45 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -17,7 +17,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_motion_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_motion_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit motion sensor accessory.""" helper = await setup_test_component(hass, create_motion_sensor_service) @@ -44,7 +44,7 @@ def create_contact_sensor_service(accessory): cur_state.value = 0 -async def test_contact_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_contact_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_contact_sensor_service) @@ -71,7 +71,7 @@ def create_smoke_sensor_service(accessory): cur_state.value = 0 -async def test_smoke_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_smoke_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_smoke_sensor_service) @@ -98,7 +98,7 @@ def create_carbon_monoxide_sensor_service(accessory): cur_state.value = 0 -async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_carbon_monoxide_sensor_service) @@ -127,7 +127,7 @@ def create_occupancy_sensor_service(accessory): cur_state.value = 0 -async def test_occupancy_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_occupancy_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit occupancy sensor accessory.""" helper = await setup_test_component(hass, create_occupancy_sensor_service) @@ -154,7 +154,7 @@ def create_leak_sensor_service(accessory): cur_state.value = 0 -async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_leak_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit leak sensor accessory.""" helper = await setup_test_component(hass, create_leak_sensor_service) @@ -174,7 +174,7 @@ async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a binary_sensor unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 1f08b578a93..57592fb7a27 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -95,7 +95,7 @@ async def test_ecobee_clear_hold_press_button(hass: HomeAssistant) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a button unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index bbb8e5a8eaa..f74f2e62772 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -17,7 +17,7 @@ def create_camera(accessory): async def test_migrate_unique_ids( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test migrating entity unique ids.""" aid = get_next_aid() @@ -33,7 +33,7 @@ async def test_migrate_unique_ids( ) -async def test_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit camera.""" helper = await setup_test_component(hass, create_camera) @@ -41,7 +41,7 @@ async def test_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_get_image(hass: HomeAssistant, utcnow) -> None: +async def test_get_image(hass: HomeAssistant) -> None: """Test getting a JPEG from a camera.""" helper = await setup_test_component(hass, create_camera) image = await camera.async_get_image(hass, helper.entity_id) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index c80016770fd..c3882553ea0 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -72,9 +72,7 @@ def create_thermostat_service_min_max(accessory): char.maxValue = 1 -async def test_climate_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_min_max) state = await helper.poll_and_get_state() @@ -89,16 +87,14 @@ def create_thermostat_service_valid_vals(accessory): char.valid_values = [0, 1, 2] -async def test_climate_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["off", "heat", "cool"] -async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -181,9 +177,7 @@ async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> N ) -async def test_climate_check_min_max_values_per_mode( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_check_min_max_values_per_mode(hass: HomeAssistant) -> None: """Test that we we get the appropriate min/max values for each mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -218,9 +212,7 @@ async def test_climate_check_min_max_values_per_mode( assert climate_state.attributes["max_temp"] == 40 -async def test_climate_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -251,9 +243,7 @@ async def test_climate_change_thermostat_temperature( ) -async def test_climate_change_thermostat_temperature_range( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature_range(hass: HomeAssistant) -> None: """Test that we can set separate heat and cool setpoints in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -287,7 +277,7 @@ async def test_climate_change_thermostat_temperature_range( async def test_climate_change_thermostat_temperature_range_iphone( - hass: HomeAssistant, utcnow + hass: HomeAssistant ) -> None: """Test that we can set all three set points at once (iPhone heat_cool mode support).""" helper = await setup_test_component(hass, create_thermostat_service) @@ -322,7 +312,7 @@ async def test_climate_change_thermostat_temperature_range_iphone( async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode( - hass: HomeAssistant, utcnow + hass: HomeAssistant ) -> None: """Test that we cannot set range values when not in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -381,7 +371,7 @@ def create_thermostat_single_set_point_auto(accessory): async def test_climate_check_min_max_values_per_mode_sspa_device( - hass: HomeAssistant, utcnow + hass: HomeAssistant ) -> None: """Test appropriate min/max values for each mode on sspa devices.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -417,9 +407,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( assert climate_state.attributes["max_temp"] == 35 -async def test_climate_set_thermostat_temp_on_sspa_device( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_set_thermostat_temp_on_sspa_device(hass: HomeAssistant) -> None: """Test setting temperature in different modes on device with single set point in auto.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -473,7 +461,7 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) -async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: +async def test_climate_set_mode_via_temp(hass: HomeAssistant) -> None: """Test setting temperature and mode at same tims.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -514,7 +502,7 @@ async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: ) -async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_humidity(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -545,7 +533,7 @@ async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) - ) -async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -602,7 +590,7 @@ async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> Non assert state.state == HVACMode.HEAT_COOL -async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: +async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -639,9 +627,7 @@ async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: assert state.attributes["hvac_action"] == "heating" -async def test_hvac_mode_vs_hvac_action_current_mode_wrong( - hass: HomeAssistant, utcnow -) -> None: +async def test_hvac_mode_vs_hvac_action_current_mode_wrong(hass: HomeAssistant) -> None: """Check that we cope with buggy HEATING_COOLING_CURRENT.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -705,9 +691,7 @@ def create_heater_cooler_service_min_max(accessory): char.maxValue = 2 -async def test_heater_cooler_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_heater_cooler_service_min_max) state = await helper.poll_and_get_state() @@ -722,18 +706,14 @@ def create_theater_cooler_service_valid_vals(accessory): char.valid_values = [1, 2] -async def test_heater_cooler_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_theater_cooler_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["heat", "cool", "off"] -async def test_heater_cooler_change_thermostat_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can change the operational mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -790,7 +770,7 @@ async def test_heater_cooler_change_thermostat_state( ) -async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: +async def test_can_turn_on_after_off(hass: HomeAssistant) -> None: """Test that we always force device from inactive to active when setting mode. This is a regression test for #81863. @@ -825,9 +805,7 @@ async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_heater_cooler_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can change the target temperature.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -870,7 +848,7 @@ async def test_heater_cooler_change_thermostat_temperature( ) -async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant) -> None: """Test that we can change the target fan speed.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -918,7 +896,7 @@ async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> No ) -async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -967,7 +945,7 @@ async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None assert state.attributes["fan_mode"] == "high" -async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1021,9 +999,7 @@ async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) assert state.state == HVACMode.HEAT_COOL -async def test_heater_cooler_hvac_mode_vs_hvac_action( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1062,7 +1038,7 @@ async def test_heater_cooler_hvac_mode_vs_hvac_action( assert state.attributes["hvac_action"] == "heating" -async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_swing_mode(hass: HomeAssistant) -> None: """Test that we can change the swing mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1093,7 +1069,7 @@ async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> N ) -async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_turn_off(hass: HomeAssistant) -> None: """Test that both hvac_action and hvac_mode return "off" when turned off.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1113,7 +1089,7 @@ async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a switch unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 49462a035e9..7d004a8a428 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -93,7 +93,7 @@ def create_window_covering_service_with_v_tilt_2(accessory): tilt_target.maxValue = 0 -async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_window_cover_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -118,7 +118,7 @@ async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -151,7 +151,7 @@ async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["obstruction-detected"] is True -async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -166,7 +166,7 @@ async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -181,7 +181,7 @@ async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -196,7 +196,7 @@ async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> N assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -211,7 +211,7 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -232,9 +232,7 @@ async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) - ) -async def test_write_window_cover_tilt_horizontal_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_write_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -255,7 +253,7 @@ async def test_write_window_cover_tilt_horizontal_2( ) -async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -276,7 +274,7 @@ async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> ) -async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -297,7 +295,7 @@ async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) - ) -async def test_window_cover_stop(hass: HomeAssistant, utcnow) -> None: +async def test_window_cover_stop(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -333,7 +331,7 @@ def create_garage_door_opener_service(accessory): return service -async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_door_state(hass: HomeAssistant) -> None: """Test that we can turn open and close a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -358,7 +356,7 @@ async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_door_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -399,7 +397,7 @@ async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a cover unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index ed3894c331b..2f66a1eea26 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -87,7 +87,6 @@ async def test_enumerate_remote( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that remote is correctly enumerated.""" await setup_test_component(hass, create_remote) @@ -139,7 +138,6 @@ async def test_enumerate_button( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_button) @@ -190,7 +188,6 @@ async def test_enumerate_doorbell( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_doorbell) @@ -241,7 +238,6 @@ async def test_handle_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, calls, ) -> None: """Test that events are handled.""" @@ -362,7 +358,6 @@ async def test_handle_events_late_setup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, calls, ) -> None: """Test that events are handled when setup happens after startup.""" diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 0f1073b877d..a9780c7f80c 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -15,7 +15,7 @@ from tests.typing import ClientSessionGenerator async def test_config_entry( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test generating diagnostics for a config entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -293,7 +293,6 @@ async def test_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - utcnow, ) -> None: """Test generating diagnostics for a device entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 7fb0d1fd55f..a836fb1c669 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -64,9 +64,7 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_remote( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow -) -> None: +async def test_remote(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test that remote is supported.""" helper = await setup_test_component(hass, create_remote) @@ -109,9 +107,7 @@ async def test_remote( assert state.attributes["event_type"] == "long_press" -async def test_button( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow -) -> None: +async def test_button(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test that a button is correctly enumerated.""" helper = await setup_test_component(hass, create_button) entity_id = "event.testdevice_button_1" @@ -148,7 +144,7 @@ async def test_button( async def test_doorbell( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that doorbell service is handled.""" helper = await setup_test_component(hass, create_doorbell) diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2fb64fc345d..7afadadcd98 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -89,7 +89,7 @@ def create_fanv2_service_without_rotation_speed(accessory): swing_mode.value = 0 -async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fan_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fan_service) @@ -104,7 +104,7 @@ async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fan_service) @@ -151,7 +151,7 @@ async def test_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component( hass, create_fanv2_service_without_rotation_speed @@ -184,7 +184,7 @@ async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) - ) -async def test_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fan_service) @@ -204,7 +204,7 @@ async def test_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fan_service) @@ -263,7 +263,7 @@ async def test_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fan_service) @@ -296,7 +296,7 @@ async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -336,7 +336,7 @@ async def test_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fan_service) @@ -367,7 +367,7 @@ async def test_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -382,7 +382,7 @@ async def test_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fanv2_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -397,7 +397,7 @@ async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -472,7 +472,7 @@ async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -492,7 +492,7 @@ async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -551,7 +551,7 @@ async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -584,7 +584,7 @@ async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage_with_min_step(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service_with_min_step) @@ -617,7 +617,7 @@ async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> N ) -async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -656,7 +656,7 @@ async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -687,7 +687,7 @@ async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -702,7 +702,7 @@ async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate(hass: HomeAssistant) -> None: """Test that we can control a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -733,7 +733,7 @@ async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -749,7 +749,7 @@ async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: async def test_v2_set_percentage_non_standard_rotation_range( - hass: HomeAssistant, utcnow + hass: HomeAssistant ) -> None: """Test that we set fan speed with a non-standard rotation range.""" helper = await setup_test_component( @@ -812,7 +812,7 @@ async def test_v2_set_percentage_non_standard_rotation_range( async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a fan unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index 718c6957356..1a1db53d8dd 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -63,7 +63,7 @@ def create_dehumidifier_service(accessory): return service -async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit humidifier on and off again.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -86,7 +86,7 @@ async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit dehumidifier on and off again.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -109,7 +109,7 @@ async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -148,7 +148,7 @@ async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -185,7 +185,7 @@ async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.attributes["humidity"] == 40 -async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -201,7 +201,7 @@ async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -217,7 +217,7 @@ async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -250,7 +250,7 @@ async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -283,7 +283,7 @@ async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -323,7 +323,7 @@ async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -363,7 +363,7 @@ async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -408,7 +408,7 @@ async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> assert state.attributes["humidity"] == 37 -async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -456,7 +456,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - async def test_migrate_entity_ids( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that we can migrate humidifier entity ids.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 7f7bec3bb2f..57d206a6025 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -46,7 +46,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_unload_on_stop(hass: HomeAssistant, utcnow) -> None: +async def test_unload_on_stop(hass: HomeAssistant) -> None: """Test async_unload is called on stop.""" await setup_test_component(hass, create_motion_sensor_service) with patch( diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 5d33d744de7..72bf579b36e 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -54,7 +54,7 @@ def create_lightbulb_service_with_color_temp(accessory): return service -async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_light_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit light on and off again.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -85,9 +85,7 @@ async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_change_light_state_color_temp( - hass: HomeAssistant, utcnow -) -> None: +async def test_switch_change_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can turn change color_temp.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -107,7 +105,7 @@ async def test_switch_change_light_state_color_temp( ) -async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -142,7 +140,7 @@ async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -170,7 +168,7 @@ async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -208,7 +206,7 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -239,7 +237,7 @@ async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the color_temp of a light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -267,7 +265,7 @@ async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 -async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -288,9 +286,7 @@ async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes["color_temp"] == 400 -async def test_light_becomes_unavailable_but_recovers( - hass: HomeAssistant, utcnow -) -> None: +async def test_light_becomes_unavailable_but_recovers(hass: HomeAssistant) -> None: """Test transition to and from unavailable state.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -318,7 +314,7 @@ async def test_light_becomes_unavailable_but_recovers( assert state.attributes["color_temp"] == 400 -async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: +async def test_light_unloaded_removed(hass: HomeAssistant) -> None: """Test entity and HKDevice are correctly unloaded and removed.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -344,7 +340,7 @@ async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a light unique id.""" aid = get_next_aid() @@ -362,7 +358,7 @@ async def test_migrate_unique_id( async def test_only_migrate_once( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index e265bf586a2..9aacda81683 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -28,7 +28,7 @@ def create_lock_service(accessory): return service -async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_lock_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit lock on and off again.""" helper = await setup_test_component(hass, create_lock_service) @@ -53,7 +53,7 @@ async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_lock_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit lock accessory.""" helper = await setup_test_component(hass, create_lock_service) @@ -118,7 +118,7 @@ async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a lock unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index e9ea1d552ce..1573fccea02 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -61,7 +61,7 @@ def create_tv_service_with_target_media_state(accessory): return service -async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_tv_service) @@ -90,7 +90,7 @@ async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_sources(hass: HomeAssistant) -> None: """Test that we can read the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -99,7 +99,7 @@ async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"] -async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_play_remote_key(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -146,7 +146,7 @@ async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_pause_remote_key(hass: HomeAssistant) -> None: """Test that we can pause a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -193,7 +193,7 @@ async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_play(hass: HomeAssistant, utcnow) -> None: +async def test_play(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -242,7 +242,7 @@ async def test_play(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause(hass: HomeAssistant, utcnow) -> None: +async def test_pause(hass: HomeAssistant) -> None: """Test that we can turn pause a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -290,7 +290,7 @@ async def test_pause(hass: HomeAssistant, utcnow) -> None: ) -async def test_stop(hass: HomeAssistant, utcnow) -> None: +async def test_stop(hass: HomeAssistant) -> None: """Test that we can stop a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -331,7 +331,7 @@ async def test_stop(hass: HomeAssistant, utcnow) -> None: ) -async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -352,7 +352,7 @@ async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source"] == "HDMI 2" -async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source_fail(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -369,7 +369,7 @@ async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a media_player unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index dedff37fa4b..d35df281eab 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -30,7 +30,7 @@ def create_switch_with_spray_level(accessory): async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a number unique id.""" aid = get_next_aid() @@ -48,7 +48,7 @@ async def test_migrate_unique_id( ) -async def test_read_number(hass: HomeAssistant, utcnow) -> None: +async def test_read_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) @@ -74,7 +74,7 @@ async def test_read_number(hass: HomeAssistant, utcnow) -> None: assert state.state == "5" -async def test_write_number(hass: HomeAssistant, utcnow) -> None: +async def test_write_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 70228ef3dbb..baae2cf8219 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -34,7 +34,7 @@ def create_service_with_temperature_units(accessory: Accessory): async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test we can migrate a select unique id.""" aid = get_next_aid() @@ -53,7 +53,7 @@ async def test_migrate_unique_id( ) -async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_read_current_mode(hass: HomeAssistant) -> None: """Test that Ecobee mode can be correctly read and show as human readable text.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) @@ -91,7 +91,7 @@ async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: assert state.state == "away" -async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_write_current_mode(hass: HomeAssistant) -> None: """Test can set a specific mode.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) @@ -139,7 +139,7 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_select(hass: HomeAssistant, utcnow) -> None: +async def test_read_select(hass: HomeAssistant) -> None: """Test the generic select can read the current value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) @@ -169,7 +169,7 @@ async def test_read_select(hass: HomeAssistant, utcnow) -> None: assert state.state == "fahrenheit" -async def test_write_select(hass: HomeAssistant, utcnow) -> None: +async def test_write_select(hass: HomeAssistant) -> None: """Test can set a value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index e15227d9d87..3134605125e 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -69,7 +69,7 @@ def create_battery_level_sensor(accessory): return service -async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -95,7 +95,7 @@ async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT -async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_not_added_twice(hass: HomeAssistant) -> None: """A standalone temperature sensor should not get a characteristic AND a service entity.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -109,7 +109,7 @@ async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) - assert created_sensors == {helper.entity_id} -async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidity_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit humidity sensor accessory.""" helper = await setup_test_component( hass, create_humidity_sensor_service, suffix="humidity" @@ -134,7 +134,7 @@ async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.HUMIDITY -async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_light_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_light_level_sensor_service, suffix="light_level" @@ -159,9 +159,7 @@ async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["device_class"] == SensorDeviceClass.ILLUMINANCE -async def test_carbon_dioxide_level_sensor_read_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_carbon_dioxide_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit carbon dioxide sensor accessory.""" helper = await setup_test_component( hass, create_carbon_dioxide_level_sensor_service, suffix="carbon_dioxide" @@ -184,7 +182,7 @@ async def test_carbon_dioxide_level_sensor_read_state( assert state.state == "20" -async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_battery_level_sensor(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery level sensor.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -211,7 +209,7 @@ async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.BATTERY -async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: +async def test_battery_charging(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's charging state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -235,7 +233,7 @@ async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: assert state.attributes["icon"] == "mdi:battery-charging-20" -async def test_battery_low(hass: HomeAssistant, utcnow) -> None: +async def test_battery_low(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's low state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -277,7 +275,7 @@ def create_switch_with_sensor(accessory): return service -async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_switch_with_sensor(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -307,7 +305,7 @@ async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: assert state.state == "50" -async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: +async def test_sensor_unavailable(hass: HomeAssistant) -> None: """Test a sensor becoming unavailable.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -384,7 +382,6 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, - utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: @@ -410,7 +407,6 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, - utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py index 583640854a6..afab63983e2 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -71,7 +71,7 @@ def create_lightbulb_service(accessory): async def test_storage_is_updated_on_add( - hass: HomeAssistant, hass_storage: dict[str, Any], utcnow + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test entity map storage is cleaned up on adding an accessory.""" await setup_test_component(hass, create_lightbulb_service) diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 8867ffc9bd1..5b6a77b75c9 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -49,7 +49,7 @@ def create_char_switch_service(accessory): on_char.value = False -async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_outlet_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit outlet on and off again.""" helper = await setup_test_component(hass, create_switch_service) @@ -74,7 +74,7 @@ async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_outlet_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit outlet accessory.""" helper = await setup_test_component(hass, create_switch_service) @@ -107,7 +107,7 @@ async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["outlet_in_use"] is True -async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_change_active_state(hass: HomeAssistant) -> None: """Test that we can turn a valve on and off again.""" helper = await setup_test_component(hass, create_valve_service) @@ -132,7 +132,7 @@ async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a valve accessory.""" helper = await setup_test_component(hass, create_valve_service) @@ -165,7 +165,7 @@ async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["in_use"] is False -async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_change_state(hass: HomeAssistant) -> None: """Test that we can turn a characteristic on and off again.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -198,7 +198,7 @@ async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit characteristic switch.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -220,7 +220,7 @@ async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a switch unique id.""" aid = get_next_aid() From 5f697494209f0baedd6198bc8645a4a41bc77604 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Dec 2023 19:39:19 +0100 Subject: [PATCH 380/927] Use Textselector in Trafikverket Camera (#105677) * Use Textselector in Trafikverket Camera * Update homeassistant/components/trafikverket_camera/strings.json Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- .../components/trafikverket_camera/config_flow.py | 8 ++++---- homeassistant/components/trafikverket_camera/strings.json | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index 104a6a470dc..a5257455e7a 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -17,7 +17,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import TextSelector from .const import DOMAIN @@ -90,7 +90,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), } ), errors=errors, @@ -123,8 +123,8 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_LOCATION): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_LOCATION): TextSelector(), } ), errors=errors, diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 651225934cd..35dbbb1f540 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -15,6 +15,9 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "Equal or part of name, description or camera id" } } } From 6dc8c2c37014de201578b5cbe880f7a1bbcecfc4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 19:40:51 +0100 Subject: [PATCH 381/927] Set volume_step in sonos media_player (#105671) --- homeassistant/components/sonos/media_player.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 27059bba180..031e4606148 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -67,7 +67,6 @@ _LOGGER = logging.getLogger(__name__) LONG_SERVICE_TIMEOUT = 30.0 UNJOIN_SERVICE_TIMEOUT = 0.1 -VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { RepeatMode.OFF: False, @@ -212,6 +211,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_volume_step = 2 / 100 def __init__(self, speaker: SonosSpeaker) -> None: """Initialize the media player entity.""" @@ -373,16 +373,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Name of the current input source.""" return self.media.source_name or None - @soco_error() - def volume_up(self) -> None: - """Volume up media player.""" - self.soco.volume += VOLUME_INCREMENT - - @soco_error() - def volume_down(self) -> None: - """Volume down media player.""" - self.soco.volume -= VOLUME_INCREMENT - @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" From 65514fbd733bdfb7f1d0b0ee43581cddea680e1e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Dec 2023 19:40:57 +0100 Subject: [PATCH 382/927] Add error translations for Sensibo (#105600) --- homeassistant/components/sensibo/climate.py | 34 +++++++++++++++---- homeassistant/components/sensibo/entity.py | 16 +++++++-- homeassistant/components/sensibo/select.py | 9 ++++- homeassistant/components/sensibo/strings.json | 32 +++++++++++++++++ homeassistant/components/sensibo/switch.py | 4 ++- tests/components/sensibo/test_climate.py | 4 +-- 6 files changed, 86 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 40aa54e5d56..89e1fafa213 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -314,11 +314,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Set new target temperature.""" if "targetTemperature" not in self.device_data.active_features: raise HomeAssistantError( - "Current mode doesn't support setting Target Temperature" + "Current mode doesn't support setting Target Temperature", + translation_domain=DOMAIN, + translation_key="no_target_temperature_in_features", ) if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise ValueError("No target temperature provided") + raise ServiceValidationError( + "No target temperature provided", + translation_domain=DOMAIN, + translation_key="no_target_temperature", + ) if temperature == self.target_temperature: return @@ -334,10 +340,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if "fanLevel" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Fanlevel") + raise HomeAssistantError( + "Current mode doesn't support setting Fanlevel", + translation_domain=DOMAIN, + translation_key="no_fan_level_in_features", + ) if fan_mode not in AVAILABLE_FAN_MODES: raise HomeAssistantError( - f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="fan_mode_not_supported", + translation_placeholders={"fan_mode": fan_mode}, ) transformation = self.device_data.fan_modes_translated @@ -379,10 +392,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if "swing" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Swing") + raise HomeAssistantError( + "Current mode doesn't support setting Swing", + translation_domain=DOMAIN, + translation_key="no_swing_in_features", + ) if swing_mode not in AVAILABLE_SWING_MODES: raise HomeAssistantError( - f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="swing_not_supported", + translation_placeholders={"swing_mode": swing_mode}, ) transformation = self.device_data.swing_modes_translated diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 0a60fc4a85d..f9056fa6624 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -26,15 +26,27 @@ def async_handle_api_call( async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False + if TYPE_CHECKING: + assert isinstance(entity.name, str) try: async with asyncio.timeout(TIMEOUT): res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: - raise HomeAssistantError from err + raise HomeAssistantError( + str(err), + translation_domain=DOMAIN, + translation_key="service_raised", + translation_placeholders={"error": str(err), "name": entity.name}, + ) from err LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: - raise HomeAssistantError(f"Could not execute service for {entity.name}") + raise HomeAssistantError( + f"Could not execute service for {entity.name}", + translation_domain=DOMAIN, + translation_key="service_result_not_true", + translation_placeholders={"name": entity.name}, + ) if ( isinstance(key := kwargs.get("key"), str) and (value := kwargs.get("value")) is not None diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index cda8a972ede..3e13c6cec70 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -106,9 +106,16 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" if self.entity_description.key not in self.device_data.active_features: + hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" raise HomeAssistantError( f"Current mode {self.device_data.hvac_mode} doesn't support setting" - f" {self.entity_description.name}" + f" {self.entity_description.name}", + translation_domain=DOMAIN, + translation_key="select_option_not_available", + translation_placeholders={ + "hvac_mode": hvac_mode, + "key": self.entity_description.key, + }, ) await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6081c668d89..a5f71e53c17 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -478,5 +478,37 @@ } } } + }, + "exceptions": { + "no_target_temperature_in_features": { + "message": "Current mode doesn't support setting target temperature" + }, + "no_target_temperature": { + "message": "No target temperature provided" + }, + "no_fan_level_in_features": { + "message": "Current mode doesn't support setting fan level" + }, + "fan_mode_not_supported": { + "message": "Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + }, + "no_swing_in_features": { + "message": "Current mode doesn't support setting swing" + }, + "swing_not_supported": { + "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + }, + "service_result_not_true": { + "message": "Could not execute service for {name}" + }, + "service_raised": { + "message": "Could not execute service for {name} with error {error}" + }, + "select_option_not_available": { + "message": "Current mode {hvac_mode} doesn't support setting {key}" + }, + "climate_react_not_available": { + "message": "Use Sensibo Enable Climate React Service once to enable switch or the Sensibo app" + } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 204ed622f13..a27307fcceb 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -184,7 +184,9 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): if self.device_data.smart_type is None: raise HomeAssistantError( "Use Sensibo Enable Climate React Service once to enable switch or the" - " Sensibo app" + " Sensibo app", + translation_domain=DOMAIN, + translation_key="climate_react_not_available", ) data: dict[str, Any] = {"enabled": value} result = await self._client.async_enable_climate_react(self._device_id, data) diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 9cf0a8972a9..71680733098 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -55,7 +55,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -438,7 +438,7 @@ async def test_climate_temperature_is_none( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises(ValueError): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, From 08b6d2af5e28faf6ebdb7f0e524b450e9f18ede1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Dec 2023 19:41:31 +0100 Subject: [PATCH 383/927] Add error translations to Yale Smart Living (#105678) --- .../yale_smart_alarm/alarm_control_panel.py | 12 ++++++++++-- homeassistant/components/yale_smart_alarm/lock.py | 14 ++++++++++++-- .../components/yale_smart_alarm/strings.json | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 7ced3487269..31851ad3ceb 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -83,7 +83,13 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): except YALE_ALL_ERRORS as error: raise HomeAssistantError( f"Could not set alarm for {self.coordinator.entry.data[CONF_NAME]}:" - f" {error}" + f" {error}", + translation_domain=DOMAIN, + translation_key="set_alarm", + translation_placeholders={ + "name": self.coordinator.entry.data[CONF_NAME], + "error": str(error), + }, ) from error if alarm_state: @@ -91,7 +97,9 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): self.async_write_ha_state() return raise HomeAssistantError( - "Could not change alarm check system ready for arming." + "Could not change alarm, check system ready for arming", + translation_domain=DOMAIN, + translation_key="could_not_change_alarm", ) @property diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 50d7b28c52b..c5a9bb79ba8 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -79,14 +79,24 @@ class YaleDoorlock(YaleEntity, LockEntity): ) except YALE_ALL_ERRORS as error: raise HomeAssistantError( - f"Could not set lock for {self.lock_name}: {error}" + f"Could not set lock for {self.lock_name}: {error}", + translation_domain=DOMAIN, + translation_key="set_lock", + translation_placeholders={ + "name": self.lock_name, + "error": str(error), + }, ) from error if lock_state: self.coordinator.data["lock_map"][self._attr_unique_id] = command self.async_write_ha_state() return - raise HomeAssistantError("Could not set lock, check system ready for lock.") + raise HomeAssistantError( + "Could not set lock, check system ready for lock", + translation_domain=DOMAIN, + translation_key="could_not_change_lock", + ) @property def is_locked(self) -> bool | None: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index a51d151d7d9..a698da20d8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -56,5 +56,19 @@ "name": "Panic button" } } + }, + "exceptions": { + "set_alarm": { + "message": "Could not set alarm for {name}: {error}" + }, + "could_not_change_alarm": { + "message": "Could not change alarm, check system ready for arming" + }, + "set_lock": { + "message": "Could not set lock for {name}: {error}" + }, + "could_not_change_lock": { + "message": "Could not set lock, check system ready for lock" + } } } From 72cb21d875f3cc342dd17523bd099f2287ce63f5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Dec 2023 19:42:11 +0100 Subject: [PATCH 384/927] Set volume_step in enigma2 media_player (#105669) --- homeassistant/components/enigma2/media_player.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index a479590f464..345ba1f8acb 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -115,6 +115,7 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_volume_step = 5 / 100 def __init__(self, name, device): """Initialize the Enigma2 device.""" @@ -185,14 +186,6 @@ class Enigma2Device(MediaPlayerEntity): """Set volume level, range 0..1.""" self.e2_box.set_volume(int(volume * 100)) - def volume_up(self) -> None: - """Volume up the media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) - - def volume_down(self) -> None: - """Volume down media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) - @property def volume_level(self): """Volume level of the media player (0..1).""" From 7084889b78a18209aaf9d1a69b44d7fb4b52b524 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 13 Dec 2023 22:35:41 +0100 Subject: [PATCH 385/927] Fix restoring UniFi clients with old unique id (#105691) Fix restoring UniFi clients with bad unique id --- homeassistant/components/unifi/controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 035cf66a983..a941e836ae2 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -260,8 +260,8 @@ class UniFiController: for entry in async_entries_for_config_entry( entity_registry, self.config_entry.entry_id ): - if entry.domain == Platform.DEVICE_TRACKER: - macs.append(entry.unique_id.split("-", 1)[0]) + if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id: + macs.append(entry.unique_id.split("-", 1)[1]) for mac in self.option_supported_clients + self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: From a16ab0d1ac5e785abb5e6a157ed9b53cdbeb6a1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 12:14:07 -1000 Subject: [PATCH 386/927] Bump zeroconf to 0.128.5 (#105694) --- 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 6738431b304..d78f33f0d91 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.128.4"] + "requirements": ["zeroconf==0.128.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1832c61712e..f01000d100b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.128.4 +zeroconf==0.128.5 # 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 2b937eb1ac3..f890d3ad9d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2832,7 +2832,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.128.4 +zeroconf==0.128.5 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f706adb50cd..ed309f4ce13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2130,7 +2130,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.128.4 +zeroconf==0.128.5 # homeassistant.components.zeversolar zeversolar==0.3.1 From aafdca88c936eb9a9be5a8127aa67372fbb29112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 20:52:37 -1000 Subject: [PATCH 387/927] Bump zeroconf to 0.129.0 (#105701) * Bump zeroconf to 0.129.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.128.5...0.129.0 * cleanup typing * remove redunant lru * revert type narrowing --- homeassistant/components/thread/discovery.py | 16 ++------- homeassistant/components/zeroconf/__init__.py | 34 ++++++------------- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 18 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 3395353b7bf..0f2997986cb 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -60,11 +60,7 @@ def async_discovery_data_from_service( except UnicodeDecodeError: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - + service_properties = service.properties border_agent_id = service_properties.get(b"id") model_name = try_decode(service_properties.get(b"mn")) network_name = try_decode(service_properties.get(b"nn")) @@ -121,10 +117,7 @@ def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscover # data is not fully in the cache, so ignore for now continue - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], info.properties) + service_properties = info.properties if not (xa := service_properties.get(b"xa")): _LOGGER.debug("Ignoring record without xa %s", info) @@ -189,10 +182,7 @@ class ThreadRouterDiscovery: return _LOGGER.debug("_add_update_service %s %s", name, service) - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) + service_properties = service.properties # We need xa and xp, bail out if either is missing if not (xa := service_properties.get(b"xa")): diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bf0984d3989..03662ef4ce6 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -128,12 +128,12 @@ class ZeroconfServiceInfo(BaseServiceInfo): @property def host(self) -> str: """Return the host.""" - return _stringify_ip_address(self.ip_address) + return str(self.ip_address) @property def addresses(self) -> list[str]: """Return the addresses.""" - return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + return [str(ip_address) for ip_address in self.ip_addresses] @bind_hass @@ -338,12 +338,13 @@ def _match_against_data( return True -def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool: +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: """Check a matcher to ensure all values in props.""" return not any( key for key in matcher - if key not in props or not _memorized_fnmatch(props[key].lower(), matcher[key]) + if key not in props + or not _memorized_fnmatch((props[key] or "").lower(), matcher[key]) ) @@ -467,7 +468,7 @@ class ZeroconfDiscovery: _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str] = info.properties + props: dict[str, str | None] = info.properties domain = None # If we can handle it as a HomeKit discovery, we do that here. @@ -563,10 +564,6 @@ def async_get_homekit_discovery( return None -# matches to the cache in zeroconf itself -_stringify_ip_address = lru_cache(maxsize=256)(str) - - def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and @@ -586,19 +583,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if not ip_address: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - - properties: dict[str, Any] = { - k.decode("ascii", "replace"): None - if v is None - else v.decode("utf-8", "replace") - for k, v in service_properties.items() - } - - assert service.server is not None, "server cannot be none if there are addresses" + if TYPE_CHECKING: + assert ( + service.server is not None + ), "server cannot be none if there are addresses" return ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, @@ -606,7 +594,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: hostname=service.server, type=service.type, name=service.name, - properties=properties, + properties=service.decoded_properties, ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index d78f33f0d91..c4d7cb923bc 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.128.5"] + "requirements": ["zeroconf==0.129.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f01000d100b..dae1f1d0732 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.128.5 +zeroconf==0.129.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 f890d3ad9d6..b7ed653568f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2832,7 +2832,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.128.5 +zeroconf==0.129.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed309f4ce13..855441eecbe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2130,7 +2130,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.128.5 +zeroconf==0.129.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9020dbb09337565ef3f85e40ebfb82b0e078bfa3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 14 Dec 2023 08:33:31 +0100 Subject: [PATCH 388/927] Remove context_recent_time property from entity base class (#105652) --- homeassistant/components/mqtt/mixins.py | 1 - homeassistant/helpers/entity.py | 10 +++------- pylint/plugins/hass_enforce_type_hints.py | 4 ---- tests/helpers/test_entity.py | 7 +++---- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 76300afb97a..ded9073ac57 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -139,7 +139,6 @@ CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", - "context_recent_time", "device_class", "device_info", "entity_category", diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cc709f4c754..4a12c012ca1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -79,6 +79,8 @@ FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) # How many times per hour we allow capabilities to be updated before logging a warning CAPABILITIES_UPDATE_LIMIT = 100 +CONTEXT_RECENT_TIME = timedelta(seconds=5) # Time that a context is considered recent + @callback def async_setup(hass: HomeAssistant) -> None: @@ -340,7 +342,6 @@ class Entity(ABC): _attr_attribution: str | None = None _attr_available: bool = True _attr_capability_attributes: Mapping[str, Any] | None = None - _attr_context_recent_time: timedelta = timedelta(seconds=5) _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None @@ -627,11 +628,6 @@ class Entity(ABC): """Flag supported features.""" return self._attr_supported_features - @property - def context_recent_time(self) -> timedelta: - """Time that a context is considered recent.""" - return self._attr_context_recent_time - @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added. @@ -942,7 +938,7 @@ class Entity(ABC): if ( self._context_set is not None and hass.loop.time() - self._context_set - > self.context_recent_time.total_seconds() + > CONTEXT_RECENT_TIME.total_seconds() ): self._context = None self._context_set = None diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f43dd9b6672..bd09f461881 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -631,10 +631,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ function_name="supported_features", return_type=["int", None], ), - TypeHintMatch( - function_name="context_recent_time", - return_type="timedelta", - ), TypeHintMatch( function_name="entity_registry_enabled_default", return_type="bool", diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e9d0906970a..911f41c0766 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -664,10 +664,9 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() - with patch.object( - entity.Entity, "context_recent_time", new_callable=PropertyMock - ) as recent: - recent.return_value = timedelta(seconds=-5) + with patch( + "homeassistant.helpers.entity.CONTEXT_RECENT_TIME", timedelta(seconds=-5) + ): ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" From 0ec3a222e3686b72c082f177d02a3a0c4cd7d431 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 14 Dec 2023 08:33:56 +0100 Subject: [PATCH 389/927] Remove device_state_attributes property from entity base class (#105650) --- homeassistant/helpers/entity.py | 9 --------- pylint/plugins/hass_enforce_type_hints.py | 4 ---- 2 files changed, 13 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4a12c012ca1..f19edaf28b9 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -540,15 +540,6 @@ class Entity(ABC): """ return None - @property - def device_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes. - - This method is deprecated, platform classes should implement - extra_state_attributes instead. - """ - return None - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index bd09f461881..b2620dd3e1e 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -587,10 +587,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ function_name="state_attributes", return_type=["dict[str, Any]", None], ), - TypeHintMatch( - function_name="device_state_attributes", - return_type=["Mapping[str, Any]", None], - ), TypeHintMatch( function_name="extra_state_attributes", return_type=["Mapping[str, Any]", None], From 82f0b28e89a4a2c0af01588a556b68923a407d5a Mon Sep 17 00:00:00 2001 From: yangbo Date: Thu, 14 Dec 2023 17:01:29 +0800 Subject: [PATCH 390/927] Bump iammeter to 0.2.1 (#95885) * Bump iammeter to 0.2.1 * Refactor sensor. * Add const.py to .coveragerc. * Add id migration. * Modify translation file. * Fix ruff test error * update asyncio.timeout import. * Delete homeassistant/components/iammeter/translations directory * Add strings.json --- .coveragerc | 1 + homeassistant/components/iammeter/__init__.py | 2 +- homeassistant/components/iammeter/const.py | 11 + .../components/iammeter/manifest.json | 2 +- homeassistant/components/iammeter/sensor.py | 452 ++++++++++++++++-- .../components/iammeter/strings.json | 69 +++ requirements_all.txt | 2 +- 7 files changed, 486 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/iammeter/const.py create mode 100644 homeassistant/components/iammeter/strings.json diff --git a/.coveragerc b/.coveragerc index 7c74ed57505..3a0cfb4a70c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -539,6 +539,7 @@ omit = homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iammeter/const.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py diff --git a/homeassistant/components/iammeter/__init__.py b/homeassistant/components/iammeter/__init__.py index b53cc35197c..46b8aaca3e7 100644 --- a/homeassistant/components/iammeter/__init__.py +++ b/homeassistant/components/iammeter/__init__.py @@ -1 +1 @@ -"""Support for IamMeter Devices.""" +"""Iammeter integration.""" diff --git a/homeassistant/components/iammeter/const.py b/homeassistant/components/iammeter/const.py new file mode 100644 index 00000000000..c2d122c9e32 --- /dev/null +++ b/homeassistant/components/iammeter/const.py @@ -0,0 +1,11 @@ +"""Constants for the Iammeter integration.""" +from __future__ import annotations + +DOMAIN = "iammeter" + +# Default config for iammeter. +DEFAULT_IP = "192.168.2.15" +DEFAULT_NAME = "IamMeter" +DEVICE_3080 = "WEM3080" +DEVICE_3080T = "WEM3080T" +DEVICE_TYPES = [DEVICE_3080, DEVICE_3080T] diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json index 191dbdedb98..f1ebecab00d 100644 --- a/homeassistant/components/iammeter/manifest.json +++ b/homeassistant/components/iammeter/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/iammeter", "iot_class": "local_polling", "loggers": ["iammeter"], - "requirements": ["iammeter==0.1.7"] + "requirements": ["iammeter==0.2.1"] } diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index ca468200370..f36eca93f28 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -2,26 +2,44 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from asyncio import timeout +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta import logging -from iammeter import real_time_api -from iammeter.power_meter import IamMeterError +from iammeter.client import IamMeter import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce +from homeassistant.helpers import debounce, entity_registry as er, update_coordinator import homeassistant.helpers.config_validation as cv +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 ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_3080, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,6 +58,51 @@ SCAN_INTERVAL = timedelta(seconds=30) PLATFORM_TIMEOUT = 8 +def _migrate_to_new_unique_id( + hass: HomeAssistant, model: str, serial_number: str +) -> None: + """Migrate old unique ids to new unique ids.""" + ent_reg = er.async_get(hass) + name_list = [ + "Voltage", + "Current", + "Power", + "ImportEnergy", + "ExportGrid", + "Frequency", + "PF", + ] + phase_list = ["A", "B", "C", "NET"] + id_phase_range = 1 if model == DEVICE_3080 else 4 + id_name_range = 5 if model == DEVICE_3080 else 7 + for row in range(0, id_phase_range): + for idx in range(0, id_name_range): + old_unique_id = f"{serial_number}-{row}-{idx}" + new_unique_id = ( + f"{serial_number}_{name_list[idx]}" + if model == DEVICE_3080 + else f"{serial_number}_{name_list[idx]}_{phase_list[row]}" + ) + entity_id = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, old_unique_id + ) + if entity_id is not None: + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Skip migration of id [%s] to [%s] because it already exists", + old_unique_id, + new_unique_id, + ) + else: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -51,23 +114,24 @@ async def async_setup_platform( config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - api = await real_time_api(config_host, config_port) - except (IamMeterError, asyncio.TimeoutError) as err: + api = await hass.async_add_executor_job( + IamMeter, config_host, config_port, config_name + ) + except asyncio.TimeoutError as err: _LOGGER.error("Device is not ready") raise PlatformNotReady from err async def async_update_data(): try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - return await api.get_data() - except (IamMeterError, asyncio.TimeoutError) as err: + async with timeout(PLATFORM_TIMEOUT): + return await hass.async_add_executor_job(api.client.get_data) + except asyncio.TimeoutError as err: raise UpdateFailed from err coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=DEFAULT_DEVICE_NAME, + name=config_name, update_method=async_update_data, update_interval=SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( @@ -75,46 +139,334 @@ async def async_setup_platform( ), ) await coordinator.async_refresh() - entities = [] - for sensor_name, (row, idx, unit) in api.iammeter.sensor_map().items(): - serial_number = api.iammeter.serial_number - uid = f"{serial_number}-{row}-{idx}" - entities.append(IamMeter(coordinator, uid, sensor_name, unit, config_name)) - async_add_entities(entities) + model = coordinator.data["Model"] + serial_number = coordinator.data["sn"] + _migrate_to_new_unique_id(hass, model, serial_number) + if model == DEVICE_3080: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080 + ) + else: # DEVICE_3080T: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080T + ) -class IamMeter(CoordinatorEntity, SensorEntity): - """Class for a sensor.""" +class IammeterSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Representation of a Sensor.""" - def __init__(self, coordinator, uid, sensor_name, unit, dev_name): - """Initialize an iammeter sensor.""" + entity_description: IammeterSensorEntityDescription + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: IammeterSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" super().__init__(coordinator) - self.uid = uid - self.sensor_name = sensor_name - self.unit = unit - self.dev_name = dev_name + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['sn']}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data["sn"])}, + manufacturer="IamMeter", + name=coordinator.name, + ) @property def native_value(self): - """Return the state of the sensor.""" - return self.coordinator.data.data[self.sensor_name] + """Return the native sensor value.""" + raw_attr = self.coordinator.data.get(self.entity_description.key, None) + if self.entity_description.value: + return self.entity_description.value(raw_attr) + return raw_attr - @property - def unique_id(self): - """Return unique id.""" - return self.uid - @property - def name(self): - """Name of this iammeter attribute.""" - return f"{self.dev_name} {self.sensor_name}" +@dataclass +class IammeterSensorEntityDescription(SensorEntityDescription): + """Describes Iammeter sensor entity.""" - @property - def icon(self): - """Icon for each sensor.""" - return "mdi:flash" + value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self.unit + +SENSOR_TYPES_3080: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) +SENSOR_TYPES_3080T: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage_A", + translation_key="voltage_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_A", + translation_key="current_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_A", + translation_key="power_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_A", + translation_key="import_energy_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_A", + translation_key="export_grid_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_A", + translation_key="frequency_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_A", + translation_key="pf_a", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_B", + translation_key="voltage_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_B", + translation_key="current_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_B", + translation_key="power_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_B", + translation_key="import_energy_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_B", + translation_key="export_grid_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_B", + translation_key="frequency_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_B", + translation_key="pf_b", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_C", + translation_key="voltage_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_C", + translation_key="current_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_C", + translation_key="power_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_C", + translation_key="import_energy_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_C", + translation_key="export_grid_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_C", + translation_key="frequency_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_C", + translation_key="pf_c", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_Net", + translation_key="voltage_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_Net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_Net", + translation_key="import_energy_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ExportGrid_Net", + translation_key="export_grid_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Frequency_Net", + translation_key="frequency_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_Net", + translation_key="pf_net", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/iammeter/strings.json b/homeassistant/components/iammeter/strings.json new file mode 100644 index 00000000000..6d0c3797dfc --- /dev/null +++ b/homeassistant/components/iammeter/strings.json @@ -0,0 +1,69 @@ +{ + "entity": { + "sensor": { + "voltage_a": { + "name": "Voltage A" + }, + "voltage_b": { + "name": "Voltage B" + }, + "voltage_c": { + "name": "Voltage C" + }, + "current_a": { + "name": "Current A" + }, + "current_b": { + "name": "Current B" + }, + "current_c": { + "name": "Current C" + }, + "power_a": { + "name": "Power A" + }, + "power_b": { + "name": "Power B" + }, + "power_c": { + "name": "Power C" + }, + "import_energy_a": { + "name": "ImportEnergy A" + }, + "import_energy_b": { + "name": "ImportEnergy B" + }, + "import_energy_c": { + "name": "ImportEnergy C" + }, + "export_grid_a": { + "name": "ExportGrid A" + }, + "export_grid_b": { + "name": "ExportGrid B" + }, + "export_grid_c": { + "name": "ExportGrid C" + }, + "frequency_a": { + "name": "Frequency A" + }, + "frequency_b": { + "name": "Frequency B" + }, + "frequency_c": { + "name": "Frequency C" + }, + "pf_a": { + "name": "PF A" + }, + "pf_b": { + "name": "PF B" + }, + "pf_c": { + "name": "PF C" + } + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index b7ed653568f..8fb23783083 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1051,7 +1051,7 @@ huawei-lte-api==1.7.3 hyperion-py==0.7.5 # homeassistant.components.iammeter -iammeter==0.1.7 +iammeter==0.2.1 # homeassistant.components.iaqualink iaqualink==0.5.0 From 2e448d2d13e44924be417d9a307439424914adb3 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 14 Dec 2023 10:15:59 +0100 Subject: [PATCH 391/927] Remove cloud details from assist pipeline (#105687) * Remove cloud details from assist pipeline * Update assist pipeline tests * Update cloud tests --- .../components/assist_pipeline/pipeline.py | 16 ++-- homeassistant/components/cloud/http_api.py | 5 +- .../assist_pipeline/test_pipeline.py | 75 +++++-------------- tests/components/cloud/test_http_api.py | 14 +++- 4 files changed, 44 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 26d599da836..2ee1c71ccb8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -115,6 +115,7 @@ async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, stt_engine_id: str | None, tts_engine_id: str | None, + pipeline_name: str, ) -> dict[str, str | None]: """Resolve settings for a default pipeline. @@ -123,7 +124,6 @@ async def _async_resolve_default_pipeline_settings( """ conversation_language = "en" pipeline_language = "en" - pipeline_name = "Home Assistant" stt_engine = None stt_language = None tts_engine = None @@ -195,9 +195,6 @@ async def _async_resolve_default_pipeline_settings( ) tts_engine_id = None - if stt_engine_id == "cloud" and tts_engine_id == "cloud": - pipeline_name = "Home Assistant Cloud" - return { "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": conversation_language, @@ -221,12 +218,17 @@ async def _async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the default stt / tts engines. """ - pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None) + pipeline_settings = await _async_resolve_default_pipeline_settings( + hass, stt_engine_id=None, tts_engine_id=None, pipeline_name="Home Assistant" + ) return await pipeline_store.async_create_item(pipeline_settings) async def async_create_default_pipeline( - hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str + hass: HomeAssistant, + stt_engine_id: str, + tts_engine_id: str, + pipeline_name: str, ) -> Pipeline | None: """Create a pipeline with default settings. @@ -236,7 +238,7 @@ async def async_create_default_pipeline( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_store = pipeline_data.pipeline_store pipeline_settings = await _async_resolve_default_pipeline_settings( - hass, stt_engine_id, tts_engine_id + hass, stt_engine_id, tts_engine_id, pipeline_name=pipeline_name ) if ( pipeline_settings["stt_engine"] != stt_engine_id diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 634a5e20b33..467ce3bcc0b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -232,7 +232,10 @@ class CloudLoginView(HomeAssistantView): new_cloud_pipeline_id: str | None = None if (cloud_assist_pipeline(hass)) is None: if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( - hass, DOMAIN, DOMAIN + hass, + stt_engine_id=DOMAIN, + tts_engine_id=DOMAIN, + pipeline_name="Home Assistant Cloud", ): new_cloud_pipeline_id = cloud_pipeline.id return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 5a84f4c2716..597d355806f 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,6 +1,6 @@ """Websocket tests for Voice Assistant integration.""" from typing import Any -from unittest.mock import ANY, AsyncMock, patch +from unittest.mock import ANY, patch import pytest @@ -21,9 +21,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES -from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTSProvider +from .conftest import MockSttProvider, MockTTSProvider -from tests.common import MockModule, flush_store, mock_integration, mock_platform +from tests.common import flush_store @pytest.fixture(autouse=True) @@ -237,13 +237,26 @@ async def test_create_default_pipeline( store = pipeline_data.pipeline_store assert len(store.data) == 1 - assert await async_create_default_pipeline(hass, "bla", "bla") is None - assert await async_create_default_pipeline(hass, "test", "test") == Pipeline( + assert ( + await async_create_default_pipeline( + hass, + stt_engine_id="bla", + tts_engine_id="bla", + pipeline_name="Bla pipeline", + ) + is None + ) + assert await async_create_default_pipeline( + hass, + stt_engine_id="test", + tts_engine_id="test", + pipeline_name="Test pipeline", + ) == Pipeline( conversation_engine="homeassistant", conversation_language="en", id=ANY, language="en", - name="Home Assistant", + name="Test pipeline", stt_engine="test", stt_language="en-US", tts_engine="test", @@ -465,53 +478,3 @@ async def test_default_pipeline_unsupported_tts_language( wake_word_entity=None, wake_word_id=None, ) - - -async def test_default_pipeline_cloud( - hass: HomeAssistant, - mock_stt_provider: MockSttProvider, - mock_tts_provider: MockTTSProvider, -) -> None: - """Test async_get_pipeline.""" - - mock_integration(hass, MockModule("cloud")) - mock_platform( - hass, - "cloud.tts", - MockTTSPlatform( - async_get_engine=AsyncMock(return_value=mock_tts_provider), - ), - ) - mock_platform( - hass, - "cloud.stt", - MockSttPlatform( - async_get_engine=AsyncMock(return_value=mock_stt_provider), - ), - ) - mock_platform(hass, "test.config_flow") - - assert await async_setup_component(hass, "tts", {"tts": {"platform": "cloud"}}) - assert await async_setup_component(hass, "stt", {"stt": {"platform": "cloud"}}) - assert await async_setup_component(hass, "assist_pipeline", {}) - - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 - - # Check the default pipeline - pipeline = async_get_pipeline(hass, None) - assert pipeline == Pipeline( - conversation_engine="homeassistant", - conversation_language="en", - id=pipeline.id, - language="en", - name="Home Assistant Cloud", - stt_engine="cloud", - stt_language="en-US", - tts_engine="cloud", - tts_language="en-US", - tts_voice="james_earl_jones", - wake_word_entity=None, - wake_word_id=None, - ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index cc6fb4a1219..2520c10b4de 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -193,7 +193,12 @@ async def test_login_view_create_pipeline( assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": "12345"} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) async def test_login_view_create_pipeline_fail( @@ -227,7 +232,12 @@ async def test_login_view_create_pipeline_fail( assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": None} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) async def test_login_view_random_exception( From 7721840298a760020316997790133ec861025978 Mon Sep 17 00:00:00 2001 From: Brig Lamoreaux Date: Thu, 14 Dec 2023 03:07:13 -0700 Subject: [PATCH 392/927] Allow multiple configs for srp energy (#96573) * Allow multiple configs. * Rename test configs. * Remove unused property * Merge branch 'dev' into srp_energy_202307.coordinator * Use title in device name. --- .../components/srp_energy/config_flow.py | 69 ++++++++------- homeassistant/components/srp_energy/const.py | 4 + homeassistant/components/srp_energy/sensor.py | 14 +++- .../components/srp_energy/strings.json | 2 +- tests/components/srp_energy/__init__.py | 29 +++++-- tests/components/srp_energy/conftest.py | 7 +- .../components/srp_energy/test_config_flow.py | 84 ++++++++++++++----- tests/components/srp_energy/test_sensor.py | 6 +- 8 files changed, 145 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index c52574ff312..ac32e005e06 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -7,12 +7,12 @@ from srpenergy.client import SrpEnergyClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from .const import CONF_IS_TOU, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_IS_TOU, DOMAIN, LOGGER async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -40,46 +40,53 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initialized by the user.""" - errors = {} - default_title: str = DEFAULT_NAME - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if self.hass.config.location_name: - default_title = self.hass.config.location_name - - if user_input: - try: - await validate_input(self.hass, user_input) - except ValueError: - # Thrown when the account id is malformed - errors["base"] = "invalid_account" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") - else: - return self.async_create_entry(title=default_title, data=user_input) - + @callback + def _show_form(self, errors: dict[str, Any]) -> FlowResult: + """Show the form to the user.""" + LOGGER.debug("Show Form") return self.async_show_form( step_id="user", data_schema=vol.Schema( { + vol.Required( + CONF_NAME, default=self.hass.config.location_name + ): str, vol.Required(CONF_ID): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_IS_TOU, default=False): bool, } ), - errors=errors or {}, + errors=errors, ) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + LOGGER.debug("Config entry") + errors: dict[str, str] = {} + if not user_input: + return self._show_form(errors) + + try: + await validate_input(self.hass, user_input) + except ValueError: + # Thrown when the account id is malformed + errors["base"] = "invalid_account" + return self._show_form(errors) + except InvalidAuth: + errors["base"] = "invalid_auth" + return self._show_form(errors) + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index bace71aca55..b2ab05f43d5 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,3 +11,7 @@ CONF_IS_TOU = "is_tou" PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) + +DEVICE_CONFIG_URL = "https://www.srpnet.com/" +DEVICE_MANUFACTURER = "srpnet.com" +DEVICE_MODEL = "Service Api" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 37aacf4ff25..9e8b8d08de9 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,10 +11,11 @@ from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SRPEnergyDataUpdateCoordinator -from .const import DOMAIN +from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN async def async_setup_entry( @@ -37,18 +38,23 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) _attr_translation_key = "energy_usage" def __init__( - self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry + 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._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, - name="SRP Energy", + name=f"SRP Energy {config_entry.title}", entry_type=DeviceEntryType.SERVICE, + manufacturer=DEVICE_MANUFACTURER, + model=DEVICE_MODEL, + configuration_url=DEVICE_CONFIG_URL, ) @property - def native_value(self) -> float: + def native_value(self) -> StateType: """Return the state of the device.""" return self.coordinator.data diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index fd963411198..35195ddb4f2 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -17,7 +17,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index 99a5da84fe2..634d589195e 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -1,21 +1,36 @@ """Tests for the SRP Energy integration.""" +from typing import Final + from homeassistant.components.srp_energy.const import CONF_IS_TOU -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -ACCNT_ID = "123456789" -ACCNT_IS_TOU = False -ACCNT_USERNAME = "abba" -ACCNT_PASSWORD = "ana" -ACCNT_NAME = "Home" +ACCNT_ID: Final = "123456789" +ACCNT_IS_TOU: Final = False +ACCNT_USERNAME: Final = "test_username" +ACCNT_PASSWORD: Final = "test_password" +ACCNT_NAME: Final = "Test Home" -TEST_USER_INPUT = { + +TEST_CONFIG_HOME: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME, CONF_ID: ACCNT_ID, CONF_USERNAME: ACCNT_USERNAME, CONF_PASSWORD: ACCNT_PASSWORD, CONF_IS_TOU: ACCNT_IS_TOU, } +ACCNT_ID_2: Final = "987654321" +ACCNT_NAME_2: Final = "Test Cabin" + +TEST_CONFIG_CABIN: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME_2, + CONF_ID: ACCNT_ID_2, + CONF_USERNAME: ACCNT_USERNAME, + CONF_PASSWORD: ACCNT_PASSWORD, + CONF_IS_TOU: ACCNT_IS_TOU, +} + MOCK_USAGE = [ ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", "1.2", "0.19"), ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", "1.3", "0.20"), diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index 3ffebe167c2..e3597081d77 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -9,10 +9,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_USAGE, TEST_USER_INPUT +from . import MOCK_USAGE, TEST_CONFIG_HOME from tests.common import MockConfigEntry @@ -42,8 +43,7 @@ def fixture_test_date(hass: HomeAssistant, hass_tz_info) -> dt.datetime | None: def fixture_mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - domain=DOMAIN, - data=TEST_USER_INPUT, + domain=DOMAIN, data=TEST_CONFIG_HOME, unique_id=TEST_CONFIG_HOME[CONF_ID] ) @@ -81,7 +81,6 @@ async def init_integration( mock_srp_energy_config_flow, ) -> MockConfigEntry: """Set up the Srp Energy integration for testing.""" - freezer.move_to(test_date) mock_config_entry.add_to_hass(hass) diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index dfd1d41e820..572b67259f1 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -1,13 +1,23 @@ """Test the SRP Energy config flow.""" from unittest.mock import MagicMock, patch -from homeassistant import config_entries from homeassistant.components.srp_energy.const import CONF_IS_TOU, DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ACCNT_ID, ACCNT_IS_TOU, ACCNT_PASSWORD, ACCNT_USERNAME, TEST_USER_INPUT +from . import ( + ACCNT_ID, + ACCNT_ID_2, + ACCNT_IS_TOU, + ACCNT_NAME, + ACCNT_NAME_2, + ACCNT_PASSWORD, + ACCNT_USERNAME, + TEST_CONFIG_CABIN, + TEST_CONFIG_HOME, +) from tests.common import MockConfigEntry @@ -17,7 +27,7 @@ async def test_show_form( ) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -29,12 +39,12 @@ async def test_show_form( return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test home" + assert result["title"] == ACCNT_NAME assert "data" in result assert result["data"][CONF_ID] == ACCNT_ID @@ -56,11 +66,11 @@ async def test_form_invalid_account( mock_srp_energy_config_flow.validate.side_effect = ValueError result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -75,11 +85,11 @@ async def test_form_invalid_auth( mock_srp_energy_config_flow.validate.return_value = False result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -94,11 +104,11 @@ async def test_form_unknown_error( mock_srp_energy_config_flow.validate.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.ABORT @@ -109,18 +119,52 @@ async def test_flow_entry_already_configured( hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test user input for config_entry that already exists.""" - user_input = { - CONF_ID: init_integration.data[CONF_ID], - CONF_USERNAME: "abba2", - CONF_PASSWORD: "ana2", - CONF_IS_TOU: False, - } + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID - assert user_input[CONF_ID] == ACCNT_ID + # Attempt a second config using same account id. This is the unique id between configs. + user_input_second = TEST_CONFIG_HOME + user_input_second[CONF_ID] = init_integration.data[CONF_ID] + + assert user_input_second[CONF_ID] == ACCNT_ID result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, data=user_input + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input_second ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_flow_multiple_configs( + hass: HomeAssistant, init_integration: MockConfigEntry, capsys +) -> None: + """Test multiple config entries.""" + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID + + # Attempt a second config using different account id. This is the unique id between configs. + assert TEST_CONFIG_CABIN[CONF_ID] != ACCNT_ID + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=TEST_CONFIG_CABIN + ) + + # Verify created + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ACCNT_NAME_2 + + assert "data" in result + assert result["data"][CONF_ID] == ACCNT_ID_2 + assert result["data"][CONF_USERNAME] == ACCNT_USERNAME + assert result["data"][CONF_PASSWORD] == ACCNT_PASSWORD + assert result["data"][CONF_IS_TOU] == ACCNT_IS_TOU + + # Verify multiple configs + entries = hass.config_entries.async_entries() + domain_entries = [entry for entry in entries if entry.domain == DOMAIN] + assert len(domain_entries) == 2 diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 32d2d971d2c..2d49fd13bf1 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -28,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.srp_energy_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -61,7 +61,7 @@ async def test_srp_entity_update_failed( 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") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None @@ -84,5 +84,5 @@ async def test_srp_entity_timeout( 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") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None From 9095027363f388d530a69b86ac53b4a643a0385f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 14 Dec 2023 12:48:15 +0100 Subject: [PATCH 393/927] Avoid mutating entity descriptions in efergy tests (#105717) --- tests/components/efergy/test_sensor.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index 45261d45933..afeb5f6e382 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,7 +1,8 @@ """The tests for Efergy sensor platform.""" from datetime import timedelta -from homeassistant.components.efergy.sensor import SENSOR_TYPES +import pytest + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -25,15 +26,18 @@ from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + async def test_sensor_readings( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test for successfully setting up the Efergy platform.""" - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True - entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) + await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) state = hass.states.get("sensor.efergy_power_usage") assert state.state == "1580" @@ -85,11 +89,6 @@ async def test_sensor_readings( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - entity = entity_registry.async_get("sensor.efergy_power_usage_728386") - assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() state = hass.states.get("sensor.efergy_power_usage_728386") assert state.state == "1628" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -101,8 +100,6 @@ async def test_multi_sensor_readings( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test for multiple sensors in one household.""" - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN, MULTI_SENSOR_TOKEN) state = hass.states.get("sensor.efergy_power_usage_728386") assert state.state == "218" From 351b07b14d29b25344a184fccb90f6302e0072d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:54:03 +0100 Subject: [PATCH 394/927] Fix issue clearing renault schedules (#105719) * Fix issue clearing renault schedules * Adjust --- .../components/renault/manifest.json | 2 +- homeassistant/components/renault/services.py | 16 ++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/renault/test_services.py | 22 +++++++++++++------ 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index e5470259aa4..98e1c8b1e7c 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.0"] + "requirements": ["renault-api==0.2.1"] } diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index d25b73cafc2..d2c7d451844 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -43,13 +43,15 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema( { vol.Required("id"): cv.positive_int, vol.Optional("activated"): cv.boolean, - vol.Optional("monday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("tuesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("wednesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("thursday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("friday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("saturday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("sunday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("monday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Any( + None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA + ), + vol.Optional("thursday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), } ) SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( diff --git a/requirements_all.txt b/requirements_all.txt index 8fb23783083..b80a23b4bf9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ raspyrfm-client==1.2.8 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.2.0 +renault-api==0.2.1 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 855441eecbe..66d3795ec48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1763,7 +1763,7 @@ rapt-ble==0.1.2 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.2.0 +renault-api==0.2.1 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 58d51eca537..7f5cb9a8184 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -203,13 +203,12 @@ async def test_service_set_charge_schedule_multi( { "id": 2, "activated": True, - "monday": {"startTime": "T12:00Z", "duration": 15}, - "tuesday": {"startTime": "T12:00Z", "duration": 15}, - "wednesday": {"startTime": "T12:00Z", "duration": 15}, - "thursday": {"startTime": "T12:00Z", "duration": 15}, - "friday": {"startTime": "T12:00Z", "duration": 15}, - "saturday": {"startTime": "T12:00Z", "duration": 15}, - "sunday": {"startTime": "T12:00Z", "duration": 15}, + "monday": {"startTime": "T12:00Z", "duration": 30}, + "tuesday": {"startTime": "T12:00Z", "duration": 30}, + "wednesday": None, + "friday": {"startTime": "T12:00Z", "duration": 30}, + "saturday": {"startTime": "T12:00Z", "duration": 30}, + "sunday": {"startTime": "T12:00Z", "duration": 30}, }, {"id": 3}, ] @@ -238,6 +237,15 @@ async def test_service_set_charge_schedule_multi( mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] assert mock_action.mock_calls[0][1] == (mock_call_data,) + # Monday updated with new values + assert mock_call_data[1].monday.startTime == "T12:00Z" + assert mock_call_data[1].monday.duration == 30 + # Wednesday has original values cleared + assert mock_call_data[1].wednesday is None + # Thursday keeps original values + assert mock_call_data[1].thursday.startTime == "T23:30Z" + assert mock_call_data[1].thursday.duration == 15 + async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry From 356ad52d10c5cf0284df93e151baa8ef4f377b62 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Thu, 14 Dec 2023 17:00:23 +0300 Subject: [PATCH 395/927] Replace Starline horn switch with button (#105728) --- .coveragerc | 1 + homeassistant/components/starline/button.py | 57 +++++++++++++++++++ homeassistant/components/starline/const.py | 5 +- .../components/starline/strings.json | 11 ++++ homeassistant/components/starline/switch.py | 12 ++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/starline/button.py diff --git a/.coveragerc b/.coveragerc index 3a0cfb4a70c..d8079d3ee65 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1216,6 +1216,7 @@ omit = homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py + homeassistant/components/starline/button.py homeassistant/components/starline/device_tracker.py homeassistant/components/starline/entity.py homeassistant/components/starline/lock.py diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py new file mode 100644 index 00000000000..af6a05206e0 --- /dev/null +++ b/homeassistant/components/starline/button.py @@ -0,0 +1,57 @@ +"""Support for StarLine button.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key="poke", + translation_key="horn", + icon="mdi:bullhorn-outline", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the StarLine button.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_state: + for description in BUTTON_TYPES: + entities.append(StarlineButton(account, device, description)) + async_add_entities(entities) + + +class StarlineButton(StarlineEntity, ButtonEntity): + """Representation of a StarLine button.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + description: ButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(account, device, description.key) + self.entity_description = description + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self._device.online + + def press(self): + """Press the button.""" + self._account.api.set_car_state(self._device.device_id, self._key, True) diff --git a/homeassistant/components/starline/const.py b/homeassistant/components/starline/const.py index be9656e70c9..06465c7b50e 100644 --- a/homeassistant/components/starline/const.py +++ b/homeassistant/components/starline/const.py @@ -7,10 +7,11 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "starline" PLATFORMS = [ - Platform.DEVICE_TRACKER, Platform.BINARY_SENSOR, - Platform.SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 800fd3a65f3..55aa8532081 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -105,6 +105,17 @@ "horn": { "name": "Horn" } + }, + "button": { + "horn": { + "name": "Horn" + } + } + }, + "issues": { + "deprecated_horn_switch": { + "title": "The Starline Horn switch entity is being removed", + "description": "Using the Horn switch is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use Horn switch entity to instead use the Horn button entity." } }, "services": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index ebe27e29e8c..b4a1561dd26 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -8,6 +8,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -48,6 +49,7 @@ SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( icon_on="mdi:access-point-network", icon_off="mdi:access-point-network-off", ), + # Deprecated and should be removed in 2024.8 StarlineSwitchEntityDescription( key="poke", translation_key="horn", @@ -119,6 +121,16 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + if self._key == "poke": + create_issue( + self.hass, + DOMAIN, + "deprecated_horn_switch", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_horn_switch", + ) self._account.api.set_car_state(self._device.device_id, self._key, True) def turn_off(self, **kwargs: Any) -> None: From 82218928e79313599002a38a37ccfdcc9a47bd21 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 14 Dec 2023 15:20:34 +0100 Subject: [PATCH 396/927] Add missing rest_command reload service to services.yaml (#105714) * Add missing rest_command reload service to services.yaml * Add missing strings.json * retrigger stale CI --- homeassistant/components/rest_command/services.yaml | 1 + homeassistant/components/rest_command/strings.json | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 homeassistant/components/rest_command/strings.json diff --git a/homeassistant/components/rest_command/services.yaml b/homeassistant/components/rest_command/services.yaml index e69de29bb2d..c983a105c93 100644 --- a/homeassistant/components/rest_command/services.yaml +++ b/homeassistant/components/rest_command/services.yaml @@ -0,0 +1 @@ +reload: diff --git a/homeassistant/components/rest_command/strings.json b/homeassistant/components/rest_command/strings.json new file mode 100644 index 00000000000..15f59ec8e29 --- /dev/null +++ b/homeassistant/components/rest_command/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads RESTful commands from the YAML-configuration." + } + } +} From 7e1dc2286f86060a8fd47cc07ccf2143b9157305 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Dec 2023 05:12:52 -1000 Subject: [PATCH 397/927] Bump bluetooth-data-tools to 1.18.0 (#105685) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.17.0...v1.18.0 --- 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/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a4c96c91727..c64784aac7c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.17.0", + "bluetooth-data-tools==1.18.0", "dbus-fast==2.21.0", "habluetooth==1.0.0" ] diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a7712de14fa..951c86bc657 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==21.0.0", - "bluetooth-data-tools==1.17.0", + "bluetooth-data-tools==1.18.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 398fcb95872..c0c7d29394d 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.17.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.18.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 440ec427f8d..6185110d795 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.17.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.18.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index ed7fc975d9e..2ddc5b582f6 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.17.0"] + "requirements": ["bluetooth-data-tools==1.18.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dae1f1d0732..8660ca963a1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.17.0 +bluetooth-data-tools==1.18.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index b80a23b4bf9..16c997ec70d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.17.0 +bluetooth-data-tools==1.18.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66d3795ec48..2dacaa08311 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.17.0 +bluetooth-data-tools==1.18.0 # homeassistant.components.bond bond-async==0.2.1 From d4a7361bc618010eab2eaf189535e5d1a46d7d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 14 Dec 2023 12:51:57 -0300 Subject: [PATCH 398/927] Bump sunweg to 2.0.1 (#105613) * chore(sunweg): minor requested changes * test(sunweg): use of fixtures * feat(sunweg): provide bad auth result on expired authentication * chore(sunweg): bump version * chore(sunweg): removed reauth * chore(sunweg): removed features out of scope * chore(sunweg): fixtures moved to conftest.py * chore(sunweg): devicetype moved to const * chore(sunweg): conftest comment Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sunweg/__init__.py | 22 ++-- homeassistant/components/sunweg/const.py | 12 ++ homeassistant/components/sunweg/manifest.json | 2 +- homeassistant/components/sunweg/sensor.py | 12 +- .../sunweg/sensor_types/inverter.py | 6 +- .../sensor_types/sensor_entity_description.py | 2 +- .../components/sunweg/sensor_types/total.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sunweg/common.py | 44 +------ tests/components/sunweg/conftest.py | 70 ++++++++++++ tests/components/sunweg/test_config_flow.py | 60 +++++----- tests/components/sunweg/test_init.py | 107 +++++++++--------- 13 files changed, 191 insertions(+), 152 deletions(-) create mode 100644 tests/components/sunweg/conftest.py diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py index f77633f4953..cdf7cc123fc 100644 --- a/homeassistant/components/sunweg/__init__.py +++ b/homeassistant/components/sunweg/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle -from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription SCAN_INTERVAL = datetime.timedelta(minutes=5) @@ -74,12 +74,12 @@ class SunWEGData: def get_api_value( self, variable: str, - device_type: str, + device_type: DeviceType, inverter_id: int = 0, deep_name: str | None = None, ): """Retrieve from a Plant the desired variable value.""" - if device_type == "total": + if device_type == DeviceType.TOTAL: return self.data.__dict__.get(variable) inverter_list = [i for i in self.data.inverters if i.id == inverter_id] @@ -87,13 +87,13 @@ class SunWEGData: return None inverter = inverter_list[0] - if device_type == "inverter": + if device_type == DeviceType.INVERTER: return inverter.__dict__.get(variable) - if device_type == "phase": + if device_type == DeviceType.PHASE: for phase in inverter.phases: if phase.name == deep_name: return phase.__dict__.get(variable) - elif device_type == "string": + elif device_type == DeviceType.STRING: for mppt in inverter.mppts: for string in mppt.strings: if string.name == deep_name: @@ -103,7 +103,7 @@ class SunWEGData: def get_data( self, entity_description: SunWEGSensorEntityDescription, - device_type: str, + device_type: DeviceType, inverter_id: int = 0, deep_name: str | None = None, ) -> StateType | datetime.datetime: @@ -113,13 +113,13 @@ class SunWEGData: entity_description.name, ) variable = entity_description.api_variable_key - previous_metric = entity_description.native_unit_of_measurement + previous_unit = entity_description.native_unit_of_measurement api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) previous_value = self.previous_values.get(variable) return_value = api_value - if entity_description.api_variable_metric is not None: + if entity_description.api_variable_unit is not None: entity_description.native_unit_of_measurement = self.get_api_value( - entity_description.api_variable_metric, + entity_description.api_variable_unit, device_type, inverter_id, deep_name, @@ -130,7 +130,7 @@ class SunWEGData: entity_description.previous_value_drop_threshold is not None and previous_value is not None and api_value is not None - and previous_metric == entity_description.native_unit_of_measurement + and previous_unit == entity_description.native_unit_of_measurement ): _LOGGER.debug( ( diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py index 12ecfb3849c..e4b2b242abf 100644 --- a/homeassistant/components/sunweg/const.py +++ b/homeassistant/components/sunweg/const.py @@ -1,6 +1,18 @@ """Define constants for the Sun WEG component.""" +from enum import Enum + from homeassistant.const import Platform + +class DeviceType(Enum): + """Device Type Enum.""" + + TOTAL = 1 + INVERTER = 2 + PHASE = 3 + STRING = 4 + + CONF_PLANT_ID = "plant_id" DEFAULT_PLANT_ID = 0 diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 271a16236d3..7f03dec11b0 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunweg/", "iot_class": "cloud_polling", "loggers": ["sunweg"], - "requirements": ["sunweg==2.0.0"] + "requirements": ["sunweg==2.0.1"] } diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py index 157595219e8..5759e3a6251 100644 --- a/homeassistant/components/sunweg/sensor.py +++ b/homeassistant/components/sunweg/sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import SunWEGData -from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType from .sensor_types.inverter import INVERTER_SENSOR_TYPES from .sensor_types.phase import PHASE_SENSOR_TYPES from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription @@ -67,7 +67,7 @@ async def async_setup_entry( name=f"{name} Total", unique_id=f"{plant_id}-{description.key}", description=description, - device_type="total", + device_type=DeviceType.TOTAL, ) for description in TOTAL_SENSOR_TYPES ] @@ -80,7 +80,7 @@ async def async_setup_entry( name=f"{device.name}", unique_id=f"{device.sn}-{description.key}", description=description, - device_type="inverter", + device_type=DeviceType.INVERTER, inverter_id=device.id, ) for device in devices @@ -96,7 +96,7 @@ async def async_setup_entry( unique_id=f"{device.sn}-{phase.name}-{description.key}", description=description, inverter_id=device.id, - device_type="phase", + device_type=DeviceType.PHASE, deep_name=phase.name, ) for device in devices @@ -113,7 +113,7 @@ async def async_setup_entry( unique_id=f"{device.sn}-{string.name}-{description.key}", description=description, inverter_id=device.id, - device_type="string", + device_type=DeviceType.STRING, deep_name=string.name, ) for device in devices @@ -137,7 +137,7 @@ class SunWEGInverter(SensorEntity): name: str, unique_id: str, description: SunWEGSensorEntityDescription, - device_type: str, + device_type: DeviceType, inverter_id: int = 0, deep_name: str | None = None, ) -> None: diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py index abb7e224836..f406efb1a83 100644 --- a/homeassistant/components/sunweg/sensor_types/inverter.py +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -16,7 +16,7 @@ INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( key="inverter_energy_today", name="Energy today", api_variable_key="_today_energy", - api_variable_metric="_today_energy_metric", + api_variable_unit="_today_energy_metric", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -26,7 +26,7 @@ INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( key="inverter_energy_total", name="Lifetime energy output", api_variable_key="_total_energy", - api_variable_metric="_total_energy_metric", + api_variable_unit="_total_energy_metric", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, @@ -45,7 +45,7 @@ INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( key="inverter_current_wattage", name="Output power", api_variable_key="_power", - api_variable_metric="_power_metric", + api_variable_unit="_power_metric", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py index c3a00df6b6f..d9a48a331b1 100644 --- a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -17,7 +17,7 @@ class SunWEGRequiredKeysMixin: class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): """Describes SunWEG sensor entity.""" - api_variable_metric: str | None = None + api_variable_unit: str | None = None previous_value_drop_threshold: float | None = None never_resets: bool = False icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py index da874be7a24..ed9d6171735 100644 --- a/homeassistant/components/sunweg/sensor_types/total.py +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -19,7 +19,7 @@ TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( key="total_energy_today", name="Energy Today", api_variable_key="_today_energy", - api_variable_metric="_today_energy_metric", + api_variable_unit="_today_energy_metric", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/requirements_all.txt b/requirements_all.txt index 16c997ec70d..b6441564376 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2553,7 +2553,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.0 +sunweg==2.0.1 # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2dacaa08311..294aa600d76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1917,7 +1917,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.0 +sunweg==2.0.1 # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py index 075af21f74b..616f5c0137f 100644 --- a/tests/components/sunweg/common.py +++ b/tests/components/sunweg/common.py @@ -1,57 +1,15 @@ """Common functions needed to setup tests for Sun WEG.""" -from datetime import datetime - -from sunweg.device import MPPT, Inverter, Phase, String -from sunweg.plant import Plant from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry -FIXTURE_USER_INPUT = { +SUNWEG_USER_INPUT = { CONF_USERNAME: "username", CONF_PASSWORD: "password", } -SUNWEG_PLANT_RESPONSE = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - datetime(2023, 2, 16, 14, 22, 37), -) - -SUNWEG_INVERTER_RESPONSE = Inverter( - 21255, - "INVERSOR01", - "J63T233018RE074", - 23.2, - 0.0, - 0.0, - "MWh", - 0, - "kWh", - 0.0, - 1, - 0, - "kW", -) - -SUNWEG_PHASE_RESPONSE = Phase("PhaseA", 120.0, 3.2, 0, 0) - -SUNWEG_MPPT_RESPONSE = MPPT("MPPT1") - -SUNWEG_STRING_RESPONSE = String("STR1", 450.3, 23.4, 0) - -SUNWEG_LOGIN_RESPONSE = True - SUNWEG_MOCK_ENTRY = MockConfigEntry( domain=DOMAIN, data={ diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py new file mode 100644 index 00000000000..68c4cab86c5 --- /dev/null +++ b/tests/components/sunweg/conftest.py @@ -0,0 +1,70 @@ +"""Conftest for SunWEG tests.""" + +from datetime import datetime + +import pytest +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + + +@pytest.fixture +def string_fixture() -> String: + """Define String fixture.""" + return String("STR1", 450.3, 23.4, 0) + + +@pytest.fixture +def mppt_fixture(string_fixture) -> MPPT: + """Define MPPT fixture.""" + mppt = MPPT("mppt") + mppt.strings.append(string_fixture) + return mppt + + +@pytest.fixture +def phase_fixture() -> Phase: + """Define Phase fixture.""" + return Phase("PhaseA", 120.0, 3.2, 0, 0) + + +@pytest.fixture +def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: + """Define inverter fixture.""" + inverter = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", + ) + inverter.phases.append(phase_fixture) + inverter.mppts.append(mppt_fixture) + return inverter + + +@pytest.fixture +def plant_fixture(inverter_fixture) -> Plant: + """Define Plant fixture.""" + plant = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), + ) + plant.inverters.append(inverter_fixture) + return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py index 64d7816f077..1298d7e93fb 100644 --- a/tests/components/sunweg/test_config_flow.py +++ b/tests/components/sunweg/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for the Sun WEG server config flow.""" -from copy import deepcopy from unittest.mock import patch from sunweg.api import APIHelper @@ -9,7 +8,7 @@ from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .common import FIXTURE_USER_INPUT, SUNWEG_LOGIN_RESPONSE, SUNWEG_PLANT_RESPONSE +from .common import SUNWEG_USER_INPUT from tests.common import MockConfigEntry @@ -32,7 +31,7 @@ async def test_incorrect_login(hass: HomeAssistant) -> None: with patch.object(APIHelper, "authenticate", return_value=False): result = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result["flow_id"], SUNWEG_USER_INPUT ) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -41,34 +40,33 @@ async def test_incorrect_login(hass: HomeAssistant) -> None: async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" + """Test registering an integration with no plants available.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() - with patch.object( - APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE - ), patch.object(APIHelper, "listPlants", return_value=[]): + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[] + ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], SUNWEG_USER_INPUT ) assert result["type"] == "abort" assert result["reason"] == "no_plants" -async def test_multiple_plant_ids(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with an selected plant_id.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() - plant_list = [deepcopy(SUNWEG_PLANT_RESPONSE), deepcopy(SUNWEG_PLANT_RESPONSE)] + user_input = SUNWEG_USER_INPUT.copy() + plant_list = [plant_fixture, plant_fixture] - with patch.object( - APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE - ), patch.object(APIHelper, "listPlants", return_value=plant_list): + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=plant_list + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input ) @@ -82,50 +80,46 @@ async def test_multiple_plant_ids(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] assert result["data"][CONF_PLANT_ID] == 123456 -async def test_one_plant_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with current plant_id.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + user_input = SUNWEG_USER_INPUT.copy() - with patch.object( - APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE - ), patch.object( + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( APIHelper, "listPlants", - return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + return_value=[plant_fixture], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] assert result["data"][CONF_PLANT_ID] == 123456 -async def test_existing_plant_configured(hass: HomeAssistant) -> None: +async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: """Test entering an existing plant_id.""" entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + user_input = SUNWEG_USER_INPUT.copy() - with patch.object( - APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE - ), patch.object( + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( APIHelper, "listPlants", - return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + return_value=[plant_fixture], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index fd9ab5ce895..12c482f6b53 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -1,51 +1,31 @@ """Tests for the Sun WEG init.""" -from copy import deepcopy import json from unittest.mock import MagicMock, patch -from sunweg.api import APIHelper -from sunweg.device import MPPT, Inverter -from sunweg.plant import Plant +from sunweg.api import APIHelper, SunWegApiError from homeassistant.components.sunweg import SunWEGData -from homeassistant.components.sunweg.const import DOMAIN +from homeassistant.components.sunweg.const import DOMAIN, DeviceType from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( SunWEGSensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import ( - SUNWEG_INVERTER_RESPONSE, - SUNWEG_LOGIN_RESPONSE, - SUNWEG_MOCK_ENTRY, - SUNWEG_MPPT_RESPONSE, - SUNWEG_PHASE_RESPONSE, - SUNWEG_PLANT_RESPONSE, - SUNWEG_STRING_RESPONSE, -) +from .common import SUNWEG_MOCK_ENTRY -async def test_methods(hass: HomeAssistant) -> None: +async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: """Test methods.""" mock_entry = SUNWEG_MOCK_ENTRY mock_entry.add_to_hass(hass) - mppt: MPPT = deepcopy(SUNWEG_MPPT_RESPONSE) - mppt.strings.append(SUNWEG_STRING_RESPONSE) - inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) - inverter.phases.append(SUNWEG_PHASE_RESPONSE) - inverter.mppts.append(mppt) - plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) - plant.inverters.append(inverter) - with patch.object( - APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE - ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( - APIHelper, "plant", return_value=plant - ), patch.object(APIHelper, "inverter", return_value=inverter), patch.object( - APIHelper, "complete_inverter" - ): + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[plant_fixture] + ), patch.object(APIHelper, "plant", return_value=plant_fixture), patch.object( + APIHelper, "inverter", return_value=inverter_fixture + ), patch.object(APIHelper, "complete_inverter"): assert await async_setup_component(hass, DOMAIN, mock_entry.data) await hass.async_block_till_done() assert await hass.config_entries.async_unload(mock_entry.entry_id) @@ -60,6 +40,17 @@ async def test_setup_wrongpass(hass: HomeAssistant) -> None: await hass.async_block_till_done() +async def test_setup_error_500(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object( + APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + async def test_sunwegdata_update_exception() -> None: """Test SunWEGData exception on update.""" api = MagicMock() @@ -69,33 +60,29 @@ async def test_sunwegdata_update_exception() -> None: assert data.data is None -async def test_sunwegdata_update_success() -> None: +async def test_sunwegdata_update_success(plant_fixture) -> None: """Test SunWEGData success on update.""" - inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) - plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) - plant.inverters.append(inverter) api = MagicMock() - api.plant = MagicMock(return_value=plant) + api.plant = MagicMock(return_value=plant_fixture) api.complete_inverter = MagicMock() data = SunWEGData(api, 0) data.update() - assert data.data.id == plant.id - assert data.data.name == plant.name - assert data.data.kwh_per_kwp == plant.kwh_per_kwp - assert data.data.last_update == plant.last_update - assert data.data.performance_rate == plant.performance_rate - assert data.data.saving == plant.saving + assert data.data.id == plant_fixture.id + assert data.data.name == plant_fixture.name + assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp + assert data.data.last_update == plant_fixture.last_update + assert data.data.performance_rate == plant_fixture.performance_rate + assert data.data.saving == plant_fixture.saving assert len(data.data.inverters) == 1 -async def test_sunwegdata_get_api_value_none() -> None: +async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: """Test SunWEGData none return on get_api_value.""" api = MagicMock() data = SunWEGData(api, 123456) - data.data = deepcopy(SUNWEG_PLANT_RESPONSE) - assert data.get_api_value("variable", "inverter", 0, "deep_name") is None - data.data.inverters.append(deepcopy(SUNWEG_INVERTER_RESPONSE)) - assert data.get_api_value("variable", "invalid type", 21255, "deep_name") is None + data.data = plant_fixture + assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None + assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None async def test_sunwegdata_get_data_drop_threshold() -> None: @@ -109,15 +96,24 @@ async def test_sunwegdata_get_data_drop_threshold() -> None: entity_description.previous_value_drop_threshold = 0.1 data.get_api_value.return_value = 3.0 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 3.0 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 3.0 ) data.get_api_value.return_value = 2.91 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 3.0 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 3.0 ) data.get_api_value.return_value = 2.8 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 2.8 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 2.8 ) @@ -132,13 +128,22 @@ async def test_sunwegdata_get_data_never_reset() -> None: entity_description.never_resets = True data.get_api_value.return_value = 3.0 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 3.0 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 3.0 ) data.get_api_value.return_value = 0 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 3.0 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 3.0 ) data.get_api_value.return_value = 2.8 assert ( - data.get_data(entity_description=entity_description, device_type="total") == 2.8 + data.get_data( + entity_description=entity_description, device_type=DeviceType.TOTAL + ) + == 2.8 ) From ffb963c4c52ec36373d5994d0fb1b66fb02e9cc0 Mon Sep 17 00:00:00 2001 From: Sylvain Rivierre Date: Thu, 14 Dec 2023 16:55:17 +0100 Subject: [PATCH 399/927] Add France to picnic countries (#105722) Picnic is also available in France "FR" country --- homeassistant/components/picnic/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index a2543c177e4..851df6f41b2 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -14,7 +14,7 @@ ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" -COUNTRY_CODES = ["NL", "DE", "BE"] +COUNTRY_CODES = ["NL", "DE", "BE", "FR"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" CART_DATA = "cart_data" From 0d9a583f4d4fd1f52b2d53ad46d5f65a185d8cf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Dec 2023 06:32:14 -1000 Subject: [PATCH 400/927] Small speed up to data entry flow steps (#105713) Instead of checking if the flow is completed with a linear tuple search each time, use a constant set --- homeassistant/data_entry_flow.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index b02fcbfcd1f..b7b1a68e792 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -46,6 +46,15 @@ RESULT_TYPE_MENU = "menu" # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +FLOW_NOT_COMPLETE_STEPS = { + FlowResultType.FORM, + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, + FlowResultType.MENU, +} + @dataclass(slots=True) class BaseServiceInfo: @@ -407,14 +416,7 @@ class FlowManager(abc.ABC): error_if_core=False, ) - if result["type"] in ( - FlowResultType.FORM, - FlowResultType.EXTERNAL_STEP, - FlowResultType.EXTERNAL_STEP_DONE, - FlowResultType.SHOW_PROGRESS, - FlowResultType.SHOW_PROGRESS_DONE, - FlowResultType.MENU, - ): + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result return result From 8d1a69ae84b660b96c97dfe6eee0464b013a0804 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Dec 2023 07:21:31 -1000 Subject: [PATCH 401/927] Migrate ESPHome bleak implementation to bleak-esphome library (#105611) --- .../components/esphome/bluetooth/__init__.py | 8 +- .../components/esphome/bluetooth/cache.py | 50 -- .../esphome/bluetooth/characteristic.py | 95 --- .../components/esphome/bluetooth/client.py | 718 ------------------ .../esphome/bluetooth/descriptor.py | 42 - .../components/esphome/bluetooth/device.py | 55 -- .../components/esphome/bluetooth/scanner.py | 48 -- .../components/esphome/bluetooth/service.py | 40 - .../components/esphome/domain_data.py | 3 +- .../components/esphome/entry_data.py | 2 +- .../components/esphome/manifest.json | 5 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../esphome/bluetooth/test_client.py | 11 +- 14 files changed, 20 insertions(+), 1063 deletions(-) delete mode 100644 homeassistant/components/esphome/bluetooth/cache.py delete mode 100644 homeassistant/components/esphome/bluetooth/characteristic.py delete mode 100644 homeassistant/components/esphome/bluetooth/client.py delete mode 100644 homeassistant/components/esphome/bluetooth/descriptor.py delete mode 100644 homeassistant/components/esphome/bluetooth/device.py delete mode 100644 homeassistant/components/esphome/bluetooth/scanner.py delete mode 100644 homeassistant/components/esphome/bluetooth/service.py diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index e7dd0697987..801b32ac2a3 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -8,6 +8,10 @@ import logging from typing import Any from aioesphomeapi import APIClient, BluetoothProxyFeature +from bleak_esphome.backend.cache import ESPHomeBluetoothCache +from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData +from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from bleak_esphome.backend.scanner import ESPHomeScanner from homeassistant.components.bluetooth import ( HaBluetoothConnector, @@ -17,10 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from ..entry_data import RuntimeEntryData -from .cache import ESPHomeBluetoothCache -from .client import ESPHomeClient, ESPHomeClientData -from .device import ESPHomeBluetoothDevice -from .scanner import ESPHomeScanner _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/esphome/bluetooth/cache.py b/homeassistant/components/esphome/bluetooth/cache.py deleted file mode 100644 index 3ec29121382..00000000000 --- a/homeassistant/components/esphome/bluetooth/cache.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Bluetooth cache for esphome.""" -from __future__ import annotations - -from collections.abc import MutableMapping -from dataclasses import dataclass, field - -from bleak.backends.service import BleakGATTServiceCollection -from lru import LRU # pylint: disable=no-name-in-module - -MAX_CACHED_SERVICES = 128 - - -@dataclass(slots=True) -class ESPHomeBluetoothCache: - """Shared cache between all ESPHome bluetooth devices.""" - - _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) - ) - _gatt_mtu_cache: MutableMapping[int, int] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) - ) - - def get_gatt_services_cache( - self, address: int - ) -> BleakGATTServiceCollection | None: - """Get the BleakGATTServiceCollection for the given address.""" - return self._gatt_services_cache.get(address) - - def set_gatt_services_cache( - self, address: int, services: BleakGATTServiceCollection - ) -> None: - """Set the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache[address] = services - - def clear_gatt_services_cache(self, address: int) -> None: - """Clear the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache.pop(address, None) - - def get_gatt_mtu_cache(self, address: int) -> int | None: - """Get the mtu cache for the given address.""" - return self._gatt_mtu_cache.get(address) - - def set_gatt_mtu_cache(self, address: int, mtu: int) -> None: - """Set the mtu cache for the given address.""" - self._gatt_mtu_cache[address] = mtu - - def clear_gatt_mtu_cache(self, address: int) -> None: - """Clear the mtu cache for the given address.""" - self._gatt_mtu_cache.pop(address, None) diff --git a/homeassistant/components/esphome/bluetooth/characteristic.py b/homeassistant/components/esphome/bluetooth/characteristic.py deleted file mode 100644 index 0db73dd3d5f..00000000000 --- a/homeassistant/components/esphome/bluetooth/characteristic.py +++ /dev/null @@ -1,95 +0,0 @@ -"""BleakGATTCharacteristicESPHome.""" -from __future__ import annotations - -import contextlib -from uuid import UUID - -from aioesphomeapi.model import BluetoothGATTCharacteristic -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.descriptor import BleakGATTDescriptor - -PROPERTY_MASKS = { - 2**n: prop - for n, prop in enumerate( - ( - "broadcast", - "read", - "write-without-response", - "write", - "notify", - "indicate", - "authenticated-signed-writes", - "extended-properties", - "reliable-writes", - "writable-auxiliaries", - ) - ) -} - - -class BleakGATTCharacteristicESPHome(BleakGATTCharacteristic): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTCharacteristic - - def __init__( - self, - obj: BluetoothGATTCharacteristic, - max_write_without_response_size: int, - service_uuid: str, - service_handle: int, - ) -> None: - """Init a BleakGATTCharacteristicESPHome.""" - super().__init__(obj, max_write_without_response_size) - self.__descriptors: list[BleakGATTDescriptor] = [] - self.__service_uuid: str = service_uuid - self.__service_handle: int = service_handle - char_props = self.obj.properties - self.__props: list[str] = [ - prop for mask, prop in PROPERTY_MASKS.items() if char_props & mask - ] - - @property - def service_uuid(self) -> str: - """Uuid of the Service containing this characteristic.""" - return self.__service_uuid - - @property - def service_handle(self) -> int: - """Integer handle of the Service containing this characteristic.""" - return self.__service_handle - - @property - def handle(self) -> int: - """Integer handle for this characteristic.""" - return self.obj.handle - - @property - def uuid(self) -> str: - """Uuid of this characteristic.""" - return self.obj.uuid - - @property - def properties(self) -> list[str]: - """Properties of this characteristic.""" - return self.__props - - @property - def descriptors(self) -> list[BleakGATTDescriptor]: - """List of descriptors for this service.""" - return self.__descriptors - - def get_descriptor(self, specifier: int | str | UUID) -> BleakGATTDescriptor | None: - """Get a descriptor by handle (int) or UUID (str or uuid.UUID).""" - with contextlib.suppress(StopIteration): - if isinstance(specifier, int): - return next(filter(lambda x: x.handle == specifier, self.descriptors)) - return next(filter(lambda x: x.uuid == str(specifier), self.descriptors)) - return None - - def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None: - """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__descriptors.append(descriptor) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py deleted file mode 100644 index 06282749649..00000000000 --- a/homeassistant/components/esphome/bluetooth/client.py +++ /dev/null @@ -1,718 +0,0 @@ -"""Bluetooth client for esphome.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass, field -from functools import partial -import logging -import sys -from typing import Any, Concatenate, ParamSpec, TypeVar -import uuid - -if sys.version_info < (3, 12): - from typing_extensions import Buffer -else: - from collections.abc import Buffer - -from aioesphomeapi import ( - ESP_CONNECTION_ERROR_DESCRIPTION, - ESPHOME_GATT_ERRORS, - APIClient, - APIVersion, - BLEConnectionError, - BluetoothConnectionDroppedError, - BluetoothProxyFeature, - DeviceInfo, -) -from aioesphomeapi.core import ( - APIConnectionError, - BluetoothGATTAPIError, - TimeoutAPIError, -) -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.client import BaseBleakClient, NotifyCallback -from bleak.backends.device import BLEDevice -from bleak.backends.service import BleakGATTServiceCollection -from bleak.exc import BleakError - -from homeassistant.core import CALLBACK_TYPE - -from .cache import ESPHomeBluetoothCache -from .characteristic import BleakGATTCharacteristicESPHome -from .descriptor import BleakGATTDescriptorESPHome -from .device import ESPHomeBluetoothDevice -from .scanner import ESPHomeScanner -from .service import BleakGATTServiceESPHome - -DEFAULT_MTU = 23 -GATT_HEADER_SIZE = 3 -DISCONNECT_TIMEOUT = 5.0 -CONNECT_FREE_SLOT_TIMEOUT = 2.0 -GATT_READ_TIMEOUT = 30.0 - -# CCCD (Characteristic Client Config Descriptor) -CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" -CCCD_NOTIFY_BYTES = b"\x01\x00" -CCCD_INDICATE_BYTES = b"\x02\x00" - -DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE -_LOGGER = logging.getLogger(__name__) - -_ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient") -_R = TypeVar("_R") -_P = ParamSpec("_P") - - -def mac_to_int(address: str) -> int: - """Convert a mac address to an integer.""" - return int(address.replace(":", ""), 16) - - -def api_error_as_bleak_error( - func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]] -) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]: - """Define a wrapper throw esphome api errors as BleakErrors.""" - - async def _async_wrap_bluetooth_operation( - self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs - ) -> _R: - # pylint: disable=protected-access - try: - return await func(self, *args, **kwargs) - except TimeoutAPIError as err: - raise asyncio.TimeoutError(str(err)) from err - except BluetoothConnectionDroppedError as ex: - _LOGGER.debug( - "%s: BLE device disconnected during %s operation", - self._description, - func.__name__, - ) - self._async_ble_device_disconnected() - raise BleakError(str(ex)) from ex - except BluetoothGATTAPIError as ex: - # If the device disconnects in the middle of an operation - # be sure to mark it as disconnected so any library using - # the proxy knows to reconnect. - # - # Because callbacks are delivered asynchronously it's possible - # that we find out about the disconnection during the operation - # before the callback is delivered. - - if ex.error.error == -1: - _LOGGER.debug( - "%s: BLE device disconnected during %s operation", - self._description, - func.__name__, - ) - self._async_ble_device_disconnected() - raise BleakError(str(ex)) from ex - except APIConnectionError as err: - raise BleakError(str(err)) from err - - return _async_wrap_bluetooth_operation - - -@dataclass(slots=True) -class ESPHomeClientData: - """Define a class that stores client data for an esphome client.""" - - bluetooth_device: ESPHomeBluetoothDevice - cache: ESPHomeBluetoothCache - client: APIClient - device_info: DeviceInfo - api_version: APIVersion - title: str - scanner: ESPHomeScanner | None - disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) - - -class ESPHomeClient(BaseBleakClient): - """ESPHome Bleak Client.""" - - def __init__( - self, - address_or_ble_device: BLEDevice | str, - *args: Any, - client_data: ESPHomeClientData, - **kwargs: Any, - ) -> None: - """Initialize the ESPHomeClient.""" - device_info = client_data.device_info - self._disconnect_callbacks = client_data.disconnect_callbacks - assert isinstance(address_or_ble_device, BLEDevice) - super().__init__(address_or_ble_device, *args, **kwargs) - self._loop = asyncio.get_running_loop() - ble_device = address_or_ble_device - self._ble_device = ble_device - self._address_as_int = mac_to_int(ble_device.address) - assert ble_device.details is not None - self._source = ble_device.details["source"] - self._cache = client_data.cache - self._bluetooth_device = client_data.bluetooth_device - self._client = client_data.client - self._is_connected = False - self._mtu: int | None = None - self._cancel_connection_state: CALLBACK_TYPE | None = None - self._notify_cancels: dict[ - int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]] - ] = {} - self._device_info = client_data.device_info - self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( - client_data.api_version - ) - self._address_type = ble_device.details["address_type"] - self._source_name = f"{client_data.title} [{self._source}]" - self._description = ( - f"{self._source_name}: {ble_device.name} - {ble_device.address}" - ) - scanner = client_data.scanner - assert scanner is not None - self._scanner = scanner - - def __str__(self) -> str: - """Return the string representation of the client.""" - return f"ESPHomeClient ({self._description})" - - def _async_disconnected_cleanup(self) -> None: - """Clean up on disconnect.""" - self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - self._is_connected = False - for _, notify_abort in self._notify_cancels.values(): - notify_abort() - self._notify_cancels.clear() - self._disconnect_callbacks.discard(self._async_esp_disconnected) - if self._cancel_connection_state: - self._cancel_connection_state() - self._cancel_connection_state = None - - def _async_ble_device_disconnected(self) -> None: - """Handle the BLE device disconnecting from the ESP.""" - was_connected = self._is_connected - self._async_disconnected_cleanup() - if was_connected: - _LOGGER.debug("%s: BLE device disconnected", self._description) - self._async_call_bleak_disconnected_callback() - - def _async_esp_disconnected(self) -> None: - """Handle the esp32 client disconnecting from us.""" - _LOGGER.debug("%s: ESP device disconnected", self._description) - # Calling _async_ble_device_disconnected calls - # _async_disconnected_cleanup which will also remove - # the disconnect callbacks - self._async_ble_device_disconnected() - - def _async_call_bleak_disconnected_callback(self) -> None: - """Call the disconnected callback to inform the bleak consumer.""" - if self._disconnected_callback: - self._disconnected_callback() - self._disconnected_callback = None - - def _on_bluetooth_connection_state( - self, - connected_future: asyncio.Future[bool], - connected: bool, - mtu: int, - error: int, - ) -> None: - """Handle a connect or disconnect.""" - _LOGGER.debug( - "%s: Connection state changed to connected=%s mtu=%s error=%s", - self._description, - connected, - mtu, - error, - ) - if connected: - self._is_connected = True - if not self._mtu: - self._mtu = mtu - self._cache.set_gatt_mtu_cache(self._address_as_int, mtu) - else: - self._async_ble_device_disconnected() - - if connected_future.done(): - return - - if error: - try: - ble_connection_error = BLEConnectionError(error) - ble_connection_error_name = ble_connection_error.name - human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error] - except (KeyError, ValueError): - ble_connection_error_name = str(error) - human_error = ESPHOME_GATT_ERRORS.get( - error, f"Unknown error code {error}" - ) - connected_future.set_exception( - BleakError( - f"Error {ble_connection_error_name} while connecting:" - f" {human_error}" - ) - ) - return - - if not connected: - connected_future.set_exception(BleakError("Disconnected")) - return - - _LOGGER.debug( - "%s: connected, registering for disconnected callbacks", - self._description, - ) - self._disconnect_callbacks.add(self._async_esp_disconnected) - connected_future.set_result(connected) - - @api_error_as_bleak_error - async def connect( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> bool: - """Connect to a specified Peripheral. - - **kwargs: - timeout (float): Timeout for required - ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. - - Returns: - Boolean representing connection status. - """ - await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT) - cache = self._cache - - self._mtu = cache.get_gatt_mtu_cache(self._address_as_int) - has_cache = bool( - dangerous_use_bleak_cache - and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING - and cache.get_gatt_services_cache(self._address_as_int) - and self._mtu - ) - connected_future: asyncio.Future[bool] = self._loop.create_future() - - timeout = kwargs.get("timeout", self._timeout) - with self._scanner.connecting(): - try: - self._cancel_connection_state = ( - await self._client.bluetooth_device_connect( - self._address_as_int, - partial(self._on_bluetooth_connection_state, connected_future), - timeout=timeout, - has_cache=has_cache, - feature_flags=self._feature_flags, - address_type=self._address_type, - ) - ) - except asyncio.CancelledError: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If we are cancelled while connecting, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception. - await connected_future - raise - except Exception as ex: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - await connected_future - connected_future.cancel(f"Unhandled exception in connect call: {ex}") - raise - await connected_future - - try: - await self._get_services( - dangerous_use_bleak_cache=dangerous_use_bleak_cache - ) - except asyncio.CancelledError: - # On cancel we must still raise cancelled error - # to avoid blocking the cancellation even if the - # disconnect call fails. - with contextlib.suppress(Exception): - await self._disconnect() - raise - except Exception: - await self._disconnect() - raise - - return True - - @api_error_as_bleak_error - async def disconnect(self) -> bool: - """Disconnect from the peripheral device.""" - return await self._disconnect() - - async def _disconnect(self) -> bool: - await self._client.bluetooth_device_disconnect(self._address_as_int) - self._async_ble_device_disconnected() - await self._wait_for_free_connection_slot(DISCONNECT_TIMEOUT) - return True - - async def _wait_for_free_connection_slot(self, timeout: float) -> None: - """Wait for a free connection slot.""" - bluetooth_device = self._bluetooth_device - if bluetooth_device.ble_connections_free: - return - _LOGGER.debug( - "%s: Out of connection slots, waiting for a free one", - self._description, - ) - async with asyncio.timeout(timeout): - await bluetooth_device.wait_for_ble_connections_free() - - @property - def is_connected(self) -> bool: - """Is Connected.""" - return self._is_connected - - @property - def mtu_size(self) -> int: - """Get ATT MTU size for active connection.""" - return self._mtu or DEFAULT_MTU - - @api_error_as_bleak_error - async def pair(self, *args: Any, **kwargs: Any) -> bool: - """Attempt to pair.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Pairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - self._raise_if_not_connected() - response = await self._client.bluetooth_device_pair(self._address_as_int) - if response.paired: - return True - _LOGGER.error( - "%s: Pairing failed due to error: %s", self._description, response.error - ) - return False - - @api_error_as_bleak_error - async def unpair(self) -> bool: - """Attempt to unpair.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Unpairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - self._raise_if_not_connected() - response = await self._client.bluetooth_device_unpair(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "%s: Unpairing failed due to error: %s", self._description, response.error - ) - return False - - @api_error_as_bleak_error - async def get_services( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> BleakGATTServiceCollection: - """Get all services registered for this GATT server. - - Returns: - A :py:class:`bleak.backends.service.BleakGATTServiceCollection` - with this device's services tree. - """ - return await self._get_services( - dangerous_use_bleak_cache=dangerous_use_bleak_cache, **kwargs - ) - - async def _get_services( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> BleakGATTServiceCollection: - """Get all services registered for this GATT server. - - Must only be called from get_services or connected - """ - self._raise_if_not_connected() - address_as_int = self._address_as_int - cache = self._cache - # If the connection version >= 3, we must use the cache - # because the esp has already wiped the services list to - # save memory. - if ( - self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING - or dangerous_use_bleak_cache - ) and (cached_services := cache.get_gatt_services_cache(address_as_int)): - _LOGGER.debug("%s: Cached services hit", self._description) - self.services = cached_services - return self.services - _LOGGER.debug("%s: Cached services miss", self._description) - esphome_services = await self._client.bluetooth_gatt_get_services( - address_as_int - ) - _LOGGER.debug("%s: Got services: %s", self._description, esphome_services) - max_write_without_response = self.mtu_size - GATT_HEADER_SIZE - services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - for service in esphome_services.services: - services.add_service(BleakGATTServiceESPHome(service)) - for characteristic in service.characteristics: - services.add_characteristic( - BleakGATTCharacteristicESPHome( - characteristic, - max_write_without_response, - service.uuid, - service.handle, - ) - ) - for descriptor in characteristic.descriptors: - services.add_descriptor( - BleakGATTDescriptorESPHome( - descriptor, - characteristic.uuid, - characteristic.handle, - ) - ) - - if not esphome_services.services: - # If we got no services, we must have disconnected - # or something went wrong on the ESP32's BLE stack. - raise BleakError("Failed to get services from remote esp") - - self.services = services - _LOGGER.debug("%s: Cached services saved", self._description) - cache.set_gatt_services_cache(address_as_int, services) - return services - - def _resolve_characteristic( - self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID - ) -> BleakGATTCharacteristic: - """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" - if (services := self.services) is None: - raise BleakError(f"{self._description}: Services have not been resolved") - if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = services.get_characteristic(char_specifier) - else: - characteristic = char_specifier - if not characteristic: - raise BleakError( - f"{self._description}: Characteristic {char_specifier} was not found!" - ) - return characteristic - - @api_error_as_bleak_error - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - cache = self._cache - cache.clear_gatt_services_cache(self._address_as_int) - cache.clear_gatt_mtu_cache(self._address_as_int) - if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: - _LOGGER.warning( - "On device cache clear is not available with this ESPHome version; " - "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", - self._device_info.name, - ) - return True - self._raise_if_not_connected() - response = await self._client.bluetooth_device_clear_cache(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "%s: Clear cache failed due to error: %s", - self._description, - response.error, - ) - return False - - @api_error_as_bleak_error - async def read_gatt_char( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - **kwargs: Any, - ) -> bytearray: - """Perform read operation on the specified GATT characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to read from, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic - object representing it. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(char_specifier) - return await self._client.bluetooth_gatt_read( - self._address_as_int, characteristic.handle, GATT_READ_TIMEOUT - ) - - @api_error_as_bleak_error - async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: - """Perform read operation on the specified GATT descriptor. - - Args: - handle (int): The handle of the descriptor to read from. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - self._raise_if_not_connected() - return await self._client.bluetooth_gatt_read_descriptor( - self._address_as_int, handle, GATT_READ_TIMEOUT - ) - - @api_error_as_bleak_error - async def write_gatt_char( - self, - characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, - data: Buffer, - response: bool = False, - ) -> None: - """Perform a write operation of the specified GATT characteristic. - - Args: - characteristic (BleakGATTCharacteristic, int, str or UUID): - The characteristic to write to, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic object - representing it. - data (bytes or bytearray): The data to send. - response (bool): If write-with-response operation should be done. - Defaults to `False`. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(characteristic) - await self._client.bluetooth_gatt_write( - self._address_as_int, characteristic.handle, bytes(data), response - ) - - @api_error_as_bleak_error - async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: - """Perform a write operation on the specified GATT descriptor. - - Args: - handle (int): The handle of the descriptor to read from. - data (bytes or bytearray): The data to send. - """ - self._raise_if_not_connected() - await self._client.bluetooth_gatt_write_descriptor( - self._address_as_int, handle, bytes(data) - ) - - @api_error_as_bleak_error - async def start_notify( - self, - characteristic: BleakGATTCharacteristic, - callback: NotifyCallback, - **kwargs: Any, - ) -> None: - """Activate notifications/indications on a characteristic. - - Callbacks must accept two inputs. The first will be a integer handle of the - characteristic generating the data and the second will be a ``bytearray`` - containing the data sent from the connected server. - - .. code-block:: python - def callback(sender: int, data: bytearray): - print(f"{sender}: {data}") - client.start_notify(char_uuid, callback) - - Args: - characteristic (BleakGATTCharacteristic): - The characteristic to activate notifications/indications on a - characteristic, specified by either integer handle, UUID or - directly by the BleakGATTCharacteristic object representing it. - callback (function): The function to be called on notification. - kwargs: Unused. - """ - self._raise_if_not_connected() - ble_handle = characteristic.handle - if ble_handle in self._notify_cancels: - raise BleakError( - f"{self._description}: Notifications are already enabled on " - f"service:{characteristic.service_uuid} " - f"characteristic:{characteristic.uuid} " - f"handle:{ble_handle}" - ) - if ( - "notify" not in characteristic.properties - and "indicate" not in characteristic.properties - ): - raise BleakError( - f"{self._description}: Characteristic {characteristic.uuid} " - "does not have notify or indicate property set." - ) - - self._notify_cancels[ - ble_handle - ] = await self._client.bluetooth_gatt_start_notify( - self._address_as_int, - ble_handle, - lambda handle, data: callback(data), - ) - - if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: - return - - # For connection v3 we are responsible for enabling notifications - # on the cccd (characteristic client config descriptor) handle since - # the esp32 will not have resolved the characteristic descriptors to - # save memory since doing so can exhaust the memory and cause a soft - # reset - cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) - if not cccd_descriptor: - raise BleakError( - f"{self._description}: Characteristic {characteristic.uuid} " - "does not have a characteristic client config descriptor." - ) - - _LOGGER.debug( - "%s: Writing to CCD descriptor %s for notifications with properties=%s", - self._description, - cccd_descriptor.handle, - characteristic.properties, - ) - supports_notify = "notify" in characteristic.properties - await self._client.bluetooth_gatt_write_descriptor( - self._address_as_int, - cccd_descriptor.handle, - CCCD_NOTIFY_BYTES if supports_notify else CCCD_INDICATE_BYTES, - wait_for_response=False, - ) - - @api_error_as_bleak_error - async def stop_notify( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - ) -> None: - """Deactivate notification/indication on a specified characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to deactivate notification/indication on, - specified by either integer handle, UUID or directly by the - BleakGATTCharacteristic object representing it. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(char_specifier) - # Do not raise KeyError if notifications are not enabled on this characteristic - # to be consistent with the behavior of the BlueZ backend - if notify_cancel := self._notify_cancels.pop(characteristic.handle, None): - notify_stop, _ = notify_cancel - await notify_stop() - - def _raise_if_not_connected(self) -> None: - """Raise a BleakError if not connected.""" - if not self._is_connected: - raise BleakError(f"{self._description} is not connected") - - def __del__(self) -> None: - """Destructor to make sure the connection state is unsubscribed.""" - if self._cancel_connection_state: - _LOGGER.warning( - ( - "%s: ESPHomeClient bleak client was not properly" - " disconnected before destruction" - ), - self._description, - ) - if not self._loop.is_closed(): - self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/homeassistant/components/esphome/bluetooth/descriptor.py b/homeassistant/components/esphome/bluetooth/descriptor.py deleted file mode 100644 index 0ba11639740..00000000000 --- a/homeassistant/components/esphome/bluetooth/descriptor.py +++ /dev/null @@ -1,42 +0,0 @@ -"""BleakGATTDescriptorESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTDescriptor -from bleak.backends.descriptor import BleakGATTDescriptor - - -class BleakGATTDescriptorESPHome(BleakGATTDescriptor): - """GATT Descriptor implementation for ESPHome backend.""" - - obj: BluetoothGATTDescriptor - - def __init__( - self, - obj: BluetoothGATTDescriptor, - characteristic_uuid: str, - characteristic_handle: int, - ) -> None: - """Init a BleakGATTDescriptorESPHome.""" - super().__init__(obj) - self.__characteristic_uuid: str = characteristic_uuid - self.__characteristic_handle: int = characteristic_handle - - @property - def characteristic_handle(self) -> int: - """Handle for the characteristic that this descriptor belongs to.""" - return self.__characteristic_handle - - @property - def characteristic_uuid(self) -> str: - """UUID for the characteristic that this descriptor belongs to.""" - return self.__characteristic_uuid - - @property - def uuid(self) -> str: - """UUID for this descriptor.""" - return self.obj.uuid - - @property - def handle(self) -> int: - """Integer handle for this descriptor.""" - return self.obj.handle diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py deleted file mode 100644 index c76562a2145..00000000000 --- a/homeassistant/components/esphome/bluetooth/device.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Bluetooth device models for esphome.""" -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -import logging - -from homeassistant.core import callback - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True) -class ESPHomeBluetoothDevice: - """Bluetooth data for a specific ESPHome device.""" - - name: str - mac_address: str - ble_connections_free: int = 0 - ble_connections_limit: int = 0 - _ble_connection_free_futures: list[asyncio.Future[int]] = field( - default_factory=list - ) - loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) - - @callback - def async_update_ble_connection_limits(self, free: int, limit: int) -> None: - """Update the BLE connection limits.""" - _LOGGER.debug( - "%s [%s]: BLE connection limits: used=%s free=%s limit=%s", - self.name, - self.mac_address, - limit - free, - free, - limit, - ) - self.ble_connections_free = free - self.ble_connections_limit = limit - if not free: - return - for fut in self._ble_connection_free_futures: - # If wait_for_ble_connections_free gets cancelled, it will - # leave a future in the list. We need to check if it's done - # before setting the result. - if not fut.done(): - fut.set_result(free) - self._ble_connection_free_futures.clear() - - async def wait_for_ble_connections_free(self) -> int: - """Wait until there are free BLE connections.""" - if self.ble_connections_free > 0: - return self.ble_connections_free - fut: asyncio.Future[int] = self.loop.create_future() - self._ble_connection_free_futures.append(fut) - return await fut diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py deleted file mode 100644 index ecbfeb4124c..00000000000 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Bluetooth scanner for esphome.""" -from __future__ import annotations - -from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisementsResponse -from bluetooth_data_tools import ( - int_to_bluetooth_address, - parse_advertisement_data_tuple, -) - -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner -from homeassistant.core import callback - - -class ESPHomeScanner(BaseHaRemoteScanner): - """Scanner for esphome.""" - - __slots__ = () - - @callback - def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: - """Call the registered callback.""" - # The mac address is a uint64, but we need a string - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - adv.name, - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - None, - {"address_type": adv.address_type}, - MONOTONIC_TIME(), - ) - - @callback - def async_on_raw_advertisements( - self, raw: BluetoothLERawAdvertisementsResponse - ) -> None: - """Call the registered callback.""" - now = MONOTONIC_TIME() - for adv in raw.advertisements: - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - *parse_advertisement_data_tuple((adv.data,)), - {"address_type": adv.address_type}, - now, - ) diff --git a/homeassistant/components/esphome/bluetooth/service.py b/homeassistant/components/esphome/bluetooth/service.py deleted file mode 100644 index 5df7d2bf603..00000000000 --- a/homeassistant/components/esphome/bluetooth/service.py +++ /dev/null @@ -1,40 +0,0 @@ -"""BleakGATTServiceESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTService -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.service import BleakGATTService - - -class BleakGATTServiceESPHome(BleakGATTService): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTService - - def __init__(self, obj: BluetoothGATTService) -> None: - """Init a BleakGATTServiceESPHome.""" - super().__init__(obj) # type: ignore[no-untyped-call] - self.__characteristics: list[BleakGATTCharacteristic] = [] - self.__handle: int = self.obj.handle - - @property - def handle(self) -> int: - """Integer handle of this service.""" - return self.__handle - - @property - def uuid(self) -> str: - """UUID for this service.""" - return self.obj.uuid - - @property - def characteristics(self) -> list[BleakGATTCharacteristic]: - """List of characteristics for this service.""" - return self.__characteristics - - def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None: - """Add a :py:class:`~BleakGATTCharacteristicESPHome` to the service. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__characteristics.append(characteristic) diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index bf7c5d9c969..6dae91c4c24 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -4,11 +4,12 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Self, cast +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from .bluetooth.cache import ESPHomeBluetoothCache from .const import DOMAIN from .entry_data import ESPHomeStorage, RuntimeEntryData diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d69a30a8c1a..a824cf0256f 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ from aioesphomeapi import ( build_unique_id, ) from aioesphomeapi.model import ButtonInfo +from bleak_esphome.backend.device import ESPHomeBluetoothDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -43,7 +44,6 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -from .bluetooth.device import ESPHomeBluetoothDevice from .const import DOMAIN from .dashboard import async_get_dashboard diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 951c86bc657..536337dfbca 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -13,11 +13,12 @@ "documentation": "https://www.home-assistant.io/integrations/esphome", "integration_type": "device", "iot_class": "local_push", - "loggers": ["aioesphomeapi", "noiseprotocol"], + "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ "aioesphomeapi==21.0.0", "bluetooth-data-tools==1.18.0", - "esphome-dashboard-api==1.2.3" + "esphome-dashboard-api==1.2.3", + "bleak-esphome==0.2.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b6441564376..c52ecea9392 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -534,6 +534,9 @@ bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 +# homeassistant.components.esphome +bleak-esphome==0.2.0 + # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 294aa600d76..bfcee9ed9b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -453,6 +453,9 @@ bellows==0.37.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 +# homeassistant.components.esphome +bleak-esphome==0.2.0 + # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index e770c75cf03..0c075aafa49 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -3,16 +3,13 @@ from __future__ import annotations from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo from bleak.exc import BleakError +from bleak_esphome.backend.cache import ESPHomeBluetoothCache +from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData +from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from bleak_esphome.backend.scanner import ESPHomeScanner import pytest from homeassistant.components.bluetooth import HaBluetoothConnector -from homeassistant.components.esphome.bluetooth.cache import ESPHomeBluetoothCache -from homeassistant.components.esphome.bluetooth.client import ( - ESPHomeClient, - ESPHomeClientData, -) -from homeassistant.components.esphome.bluetooth.device import ESPHomeBluetoothDevice -from homeassistant.components.esphome.bluetooth.scanner import ESPHomeScanner from homeassistant.core import HomeAssistant from tests.components.bluetooth import generate_ble_device From a488d120b71d636c176bba04c1e1c4a00fa41f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 14 Dec 2023 18:59:04 +0100 Subject: [PATCH 402/927] Update AEMET-OpenData to v0.4.7 (#105676) --- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 544931b50b5..2bc30860803 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.4.6"] + "requirements": ["AEMET-OpenData==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index c52ecea9392..aaffa91dc0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.6 +AEMET-OpenData==0.4.7 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfcee9ed9b0..8c2da3a2036 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.4.6 +AEMET-OpenData==0.4.7 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 From 40f914214beb2c2e630a9d59bf8b08748ac6b90a Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 14 Dec 2023 12:59:37 -0500 Subject: [PATCH 403/927] Fix Fully Kiosk Browser MQTT event callbacks with non-standard event topics (#105735) --- .../components/fully_kiosk/entity.py | 4 +++- tests/components/fully_kiosk/test_switch.py | 24 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 5fd9f75a6a0..b053508ae41 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -74,7 +74,8 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit @callback def message_callback(message: mqtt.ReceiveMessage) -> None: payload = json.loads(message.payload) - event_callback(**payload) + if "event" in payload and payload["event"] == event: + event_callback(**payload) topic_template = data["settings"]["mqttEventTopic"] topic = ( @@ -82,4 +83,5 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit .replace("$event", event) .replace("$deviceId", data["deviceID"]) ) + return await mqtt.async_subscribe(self.hass, topic, message_callback) diff --git a/tests/components/fully_kiosk/test_switch.py b/tests/components/fully_kiosk/test_switch.py index 20b5ed11998..3c0874384c2 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -107,19 +107,35 @@ async def test_switches_mqtt_update( assert entity assert entity.state == "on" - async_fire_mqtt_message(hass, "fully/event/onScreensaverStart/abcdef-123456", "{}") + async_fire_mqtt_message( + hass, + "fully/event/onScreensaverStart/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "onScreensaverStart"}', + ) entity = hass.states.get("switch.amazon_fire_screensaver") assert entity.state == "on" - async_fire_mqtt_message(hass, "fully/event/onScreensaverStop/abcdef-123456", "{}") + async_fire_mqtt_message( + hass, + "fully/event/onScreensaverStop/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "onScreensaverStop"}', + ) entity = hass.states.get("switch.amazon_fire_screensaver") assert entity.state == "off" - async_fire_mqtt_message(hass, "fully/event/screenOff/abcdef-123456", "{}") + async_fire_mqtt_message( + hass, + "fully/event/screenOff/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "screenOff"}', + ) entity = hass.states.get("switch.amazon_fire_screen") assert entity.state == "off" - async_fire_mqtt_message(hass, "fully/event/screenOn/abcdef-123456", "{}") + async_fire_mqtt_message( + hass, + "fully/event/screenOn/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "screenOn"}', + ) entity = hass.states.get("switch.amazon_fire_screen") assert entity.state == "on" From 032d120a26245940d6de51ac5d8d31af0a6a5b9f Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 14 Dec 2023 13:59:39 -0500 Subject: [PATCH 404/927] Include Envoy firmware version in Enphase diagnostics (#105742) * Include Envoy firmware version in Enphase diagnostics * Update tests --- homeassistant/components/enphase_envoy/diagnostics.py | 1 + tests/components/enphase_envoy/conftest.py | 1 + tests/components/enphase_envoy/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 3 insertions(+) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1d589cfb176..7b8a3e03270 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -39,6 +39,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), + "envoy_firmware": coordinator.envoy.firmware, "data": coordinator.data, }, TO_REDACT, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index c1fb03545cb..185f65aa892 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -49,6 +49,7 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): """Define a mocked Envoy fixture.""" mock_envoy = Mock(spec=Envoy) mock_envoy.serial_number = serial_number + mock_envoy.firmware = "7.1.2" mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index f0021e1934a..9266ffcf94e 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -25,5 +25,6 @@ 'unique_id': '**REDACTED**', 'version': 1, }), + 'envoy_firmware': '7.1.2', }) # --- From cc75430af31306e265a658b6b2f41ededf64018a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Dec 2023 20:28:08 +0100 Subject: [PATCH 405/927] Disable user profiles on login screen (#105749) --- homeassistant/components/auth/login_flow.py | 21 ----------- homeassistant/components/person/__init__.py | 36 +++---------------- tests/components/auth/test_login_flow.py | 13 +------ tests/components/person/test_init.py | 39 ++------------------- 4 files changed, 7 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 96255f59c7b..9b96e57dbd3 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,7 +91,6 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_cloud_connection -from homeassistant.util.network import is_local from . import indieauth @@ -165,8 +164,6 @@ class AuthProvidersView(HomeAssistantView): providers = [] for provider in hass.auth.auth_providers: - additional_data = {} - if provider.type == "trusted_networks": if cloud_connection: # Skip quickly as trusted networks are not available on cloud @@ -179,30 +176,12 @@ class AuthProvidersView(HomeAssistantView): except InvalidAuthError: # Not a trusted network, so we don't expose that trusted_network authenticator is setup continue - elif ( - provider.type == "homeassistant" - and not cloud_connection - and is_local(remote_address) - and "person" in hass.config.components - ): - # We are local, return user id and username - users = await provider.store.async_get_users() - additional_data["users"] = { - user.id: credentials.data["username"] - for user in users - for credentials in user.credentials - if ( - credentials.auth_provider_type == provider.type - and credentials.auth_provider_id == provider.id - ) - } providers.append( { "name": provider.name, "id": provider.id, "type": provider.type, - **additional_data, } ) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index b6f8b5b2db6..c796cb8d843 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from http import HTTPStatus -from ipaddress import ip_address import logging from typing import Any @@ -51,12 +50,10 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) @@ -588,33 +585,8 @@ class ListPersonsView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Return a list of persons if request comes from a local IP.""" - try: - remote_address = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - return self.json_message( - message="Invalid remote IP", - status_code=HTTPStatus.BAD_REQUEST, - message_code="invalid_remote_ip", - ) - - hass: HomeAssistant = request.app["hass"] - if is_cloud_connection(hass) or not is_local(remote_address): - return self.json_message( - message="Not local", - status_code=HTTPStatus.BAD_REQUEST, - message_code="not_local", - ) - - yaml, storage, _ = hass.data[DOMAIN] - persons = [*yaml.async_items(), *storage.async_items()] - - return self.json( - { - person[ATTR_USER_ID]: { - ATTR_NAME: person[ATTR_NAME], - CONF_PICTURE: person.get(CONF_PICTURE), - } - for person in persons - if person.get(ATTR_USER_ID) - } + return self.json_message( + message="Not local", + status_code=HTTPStatus.BAD_REQUEST, + message_code="not_local", ) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 639bbb9a9cb..27652ca2be4 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,12 +1,10 @@ """Tests for the login flow.""" -from collections.abc import Callable from http import HTTPStatus from typing import Any from unittest.mock import patch import pytest -from homeassistant.auth.models import User from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -67,22 +65,16 @@ async def _test_fetch_auth_providers_home_assistant( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ip: str, - additional_expected_fn: Callable[[User], dict[str, Any]], ) -> None: """Test fetching auth providers for homeassistant auth provider.""" client = await async_setup_auth( hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip ) - provider = hass.auth.auth_providers[0] - credentials = await provider.async_get_or_create_credentials({"username": "hello"}) - user = await hass.auth.async_get_or_create_user(credentials) - expected = { "name": "Home Assistant Local", "type": "homeassistant", "id": None, - **additional_expected_fn(user), } resp = await client.get("/auth/providers") @@ -105,9 +97,7 @@ async def test_fetch_auth_providers_home_assistant_person_not_loaded( ip: str, ) -> None: """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" - await _test_fetch_auth_providers_home_assistant( - hass, aiohttp_client, ip, lambda _: {} - ) + await _test_fetch_auth_providers_home_assistant(hass, aiohttp_client, ip) @pytest.mark.parametrize( @@ -134,7 +124,6 @@ async def test_fetch_auth_providers_home_assistant_person_loaded( hass, aiohttp_client, ip, - lambda user: {"users": {user.id: user.name}} if is_local else {}, ) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 4d7781a095f..1866f682b55 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -from collections.abc import Callable from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -31,7 +30,6 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -852,42 +850,10 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: ] -@pytest.mark.parametrize( - ("ip", "status_code", "expected_fn"), - [ - ( - "192.168.0.10", - HTTPStatus.OK, - lambda user: { - user["user_id"]: {"name": user["name"], "picture": user["picture"]} - }, - ), - ( - "::ffff:192.168.0.10", - HTTPStatus.OK, - lambda user: { - user["user_id"]: {"name": user["name"], "picture": user["picture"]} - }, - ), - ( - "1.2.3.4", - HTTPStatus.BAD_REQUEST, - lambda _: {"code": "not_local", "message": "Not local"}, - ), - ( - "2001:db8::1", - HTTPStatus.BAD_REQUEST, - lambda _: {"code": "not_local", "message": "Not local"}, - ), - ], -) async def test_list_persons( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, hass_admin_user: MockUser, - ip: str, - status_code: HTTPStatus, - expected_fn: Callable[[dict[str, Any]], dict[str, Any]], ) -> None: """Test listing persons from a not local ip address.""" @@ -902,11 +868,10 @@ async def test_list_persons( assert await async_setup_component(hass, DOMAIN, config) await async_setup_component(hass, "api", {}) - mock_real_ip(hass.http.app)(ip) client = await hass_client_no_auth() resp = await client.get("/api/person/list") - assert resp.status == status_code + assert resp.status == HTTPStatus.BAD_REQUEST result = await resp.json() - assert result == expected_fn(admin) + assert result == {"code": "not_local", "message": "Not local"} From 34c76859278a605c653347eff62e0c6f90e831f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Dec 2023 10:27:52 -1000 Subject: [PATCH 406/927] Bump bleak-esphome to 0.3.0 (#105748) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 536337dfbca..9ae6637876c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "aioesphomeapi==21.0.0", "bluetooth-data-tools==1.18.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==0.2.0" + "bleak-esphome==0.3.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index aaffa91dc0e..41b26a9980c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -535,7 +535,7 @@ bimmer-connected[china]==0.14.6 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==0.2.0 +bleak-esphome==0.3.0 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c2da3a2036..f76d191bb78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ bellows==0.37.3 bimmer-connected[china]==0.14.6 # homeassistant.components.esphome -bleak-esphome==0.2.0 +bleak-esphome==0.3.0 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From e0553064825a9861bb00aad4b83688b4f7e6fab1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 14 Dec 2023 22:05:39 +0100 Subject: [PATCH 407/927] Bump pytrafikverket to 0.3.9.2 (#105752) --- homeassistant/components/trafikverket_camera/manifest.json | 2 +- homeassistant/components/trafikverket_ferry/manifest.json | 2 +- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 31eb911e24d..d7631ada680 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 7f750c26c57..e1c86038986 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index b68a56b3793..83dd0e726ee 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index bd4b2b99b6a..1f27346b3a8 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41b26a9980c..f8be3735251 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2255,7 +2255,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.1 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f76d191bb78..a08396ccd6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1694,7 +1694,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.1 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 From 6c5ca58405bea0a723183f0fcc75959586261eab Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 14 Dec 2023 15:53:22 -0600 Subject: [PATCH 408/927] Set todo item status in intent (#105743) --- homeassistant/components/todo/intent.py | 6 ++++-- tests/components/todo/test_init.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index ba3545d8dfd..4cf62c6391d 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -6,7 +6,7 @@ from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, TodoItem, TodoListEntity +from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity INTENT_LIST_ADD_ITEM = "HassListAddItem" @@ -47,7 +47,9 @@ class ListAddItemIntent(intent.IntentHandler): assert target_list is not None # Add to list - await target_list.async_create_todo_item(TodoItem(item)) + await target_list.async_create_todo_item( + TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) + ) response = intent_obj.create_response() response.response_type = intent.IntentResponseType.ACTION_DONE diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 90b06858e00..0edca7a7ef6 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -1038,6 +1038,7 @@ async def test_add_item_intent( assert len(entity1.items) == 1 assert len(entity2.items) == 0 assert entity1.items[0].summary == "beer" + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION entity1.items.clear() # Add to second list @@ -1052,6 +1053,7 @@ async def test_add_item_intent( assert len(entity1.items) == 0 assert len(entity2.items) == 1 assert entity2.items[0].summary == "cheese" + assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION # List name is case insensitive response = await intent.async_handle( @@ -1065,6 +1067,7 @@ async def test_add_item_intent( assert len(entity1.items) == 0 assert len(entity2.items) == 2 assert entity2.items[1].summary == "wine" + assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION # Missing list with pytest.raises(intent.IntentHandleError): From f4c8920231428af5bb1bad2e0e8c8dd53d018702 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 14 Dec 2023 23:36:12 +0100 Subject: [PATCH 409/927] Bump brottsplatskartan 1.0.5 (#105759) --- .../brottsplatskartan/config_flow.py | 3 ++- .../components/brottsplatskartan/const.py | 24 ------------------- .../brottsplatskartan/manifest.json | 2 +- .../components/brottsplatskartan/sensor.py | 23 ++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 23 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index ac9a764179e..39c7421fa92 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any import uuid +from brottsplatskartan import AREAS import voluptuous as vol from homeassistant import config_entries @@ -11,7 +12,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN +from .const import CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index b53a39755a6..94b4a7e7280 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -12,27 +12,3 @@ LOGGER = logging.getLogger(__package__) CONF_AREA = "area" CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" - -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 14c4a5e39c2..0a386094bae 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], - "requirements": ["brottsplatskartan==0.0.1"] + "requirements": ["brottsplatskartan==1.0.5"] } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index df17832f695..b30b31be985 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from typing import Literal from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan @@ -29,9 +30,11 @@ async def async_setup_entry( app = entry.data[CONF_APP_ID] name = entry.title - bpk = BrottsplatsKartan(app=app, area=area, latitude=latitude, longitude=longitude) + bpk = BrottsplatsKartan( + app=app, areas=[area] if area else None, latitude=latitude, longitude=longitude + ) - async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id)], True) + async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id, area)], True) class BrottsplatskartanSensor(SensorEntity): @@ -41,9 +44,12 @@ class BrottsplatskartanSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: + def __init__( + self, bpk: BrottsplatsKartan, name: str, entry_id: str, area: str | None + ) -> None: """Initialize the Brottsplatskartan sensor.""" self._brottsplatskartan = bpk + self._area = area self._attr_unique_id = entry_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -56,12 +62,19 @@ class BrottsplatskartanSensor(SensorEntity): """Update device state.""" incident_counts: defaultdict[str, int] = defaultdict(int) - incidents = self._brottsplatskartan.get_incidents() + get_incidents: dict[str, list] | Literal[ + False + ] = self._brottsplatskartan.get_incidents() - if incidents is False: + if get_incidents is False: LOGGER.debug("Problems fetching incidents") return + if self._area: + incidents = get_incidents.get(self._area) or [] + else: + incidents = get_incidents.get("latlng") or [] + for incident in incidents: if (incident_type := incident.get("title_type")) is not None: incident_counts[incident_type] += 1 diff --git a/requirements_all.txt b/requirements_all.txt index f8be3735251..2584e5db3b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -589,7 +589,7 @@ broadlink==0.18.3 brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a08396ccd6c..79a4aca339f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -497,7 +497,7 @@ broadlink==0.18.3 brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 From 4da04a358ab00ea471f86198aa6689b6bb74f3b2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 14 Dec 2023 23:56:08 +0100 Subject: [PATCH 410/927] Refactor cloud assist pipeline (#105723) * Refactor cloud assist pipeline * Return None early --- .../components/cloud/assist_pipeline.py | 44 +++++++++++++++++++ homeassistant/components/cloud/http_api.py | 34 +++----------- tests/components/cloud/test_http_api.py | 6 +-- 3 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/cloud/assist_pipeline.py diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py new file mode 100644 index 00000000000..8054b3bd953 --- /dev/null +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -0,0 +1,44 @@ +"""Handle Cloud assist pipelines.""" +from homeassistant.components.assist_pipeline import ( + async_create_default_pipeline, + async_get_pipelines, + async_setup_pipeline_store, +) +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: + """Create a cloud assist pipeline.""" + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None. + + Check if a cloud pipeline already exists with + legacy cloud engine id. + """ + for pipeline in async_get_pipelines(hass): + if ( + pipeline.conversation_engine == HOME_ASSISTANT_AGENT + and pipeline.stt_engine == DOMAIN + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + + if (cloud_assist_pipeline(hass)) is not None or ( + cloud_pipeline := await async_create_default_pipeline( + hass, + stt_engine_id=DOMAIN, + tts_engine_id=DOMAIN, + pipeline_name="Home Assistant Cloud", + ) + ) is None: + return None + + return cloud_pipeline.id diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 467ce3bcc0b..c937a415cda 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,4 +1,6 @@ """The HTTP api to control the cloud integration.""" +from __future__ import annotations + import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress @@ -16,7 +18,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import assist_pipeline, conversation, websocket_api +from homeassistant.components import websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -32,6 +34,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DOMAIN, @@ -210,34 +213,11 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" - - def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: - """Return the ID of a cloud-enabled assist pipeline or None.""" - for pipeline in assist_pipeline.async_get_pipelines(hass): - if ( - pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT - and pipeline.stt_engine == DOMAIN - and pipeline.tts_engine == DOMAIN - ): - return pipeline.id - return None - - hass = request.app["hass"] - cloud = hass.data[DOMAIN] + hass: HomeAssistant = request.app["hass"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - # Make sure the pipeline store is loaded, needed because assist_pipeline - # is an after dependency of cloud - await assist_pipeline.async_setup_pipeline_store(hass) - new_cloud_pipeline_id: str | None = None - if (cloud_assist_pipeline(hass)) is None: - if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( - hass, - stt_engine_id=DOMAIN, - tts_engine_id=DOMAIN, - pipeline_name="Home Assistant Cloud", - ): - new_cloud_pipeline_id = cloud_pipeline.id + new_cloud_pipeline_id = await async_create_cloud_pipeline(hass) return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 2520c10b4de..a04729faf67 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -150,7 +150,7 @@ async def test_login_view_existing_pipeline( cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", ) as create_pipeline_mock: req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} @@ -183,7 +183,7 @@ async def test_login_view_create_pipeline( cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=AsyncMock(id="12345"), ) as create_pipeline_mock: req = await cloud_client.post( @@ -222,7 +222,7 @@ async def test_login_view_create_pipeline_fail( cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=None, ) as create_pipeline_mock: req = await cloud_client.post( From cffb51ebec5a681878f7acd88d10e1e53e8130ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 00:09:05 +0100 Subject: [PATCH 411/927] Set volume_step in monoprice media_player (#105670) --- .../components/monoprice/media_player.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 92b98abf374..40ea9f85a7c 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -127,6 +127,7 @@ class MonopriceZone(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None + _attr_volume_step = 1 / MAX_VOLUME def __init__(self, monoprice, sources, namespace, zone_id): """Initialize new zone.""" @@ -210,17 +211,3 @@ class MonopriceZone(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME)) - - def volume_up(self) -> None: - """Volume up the media player.""" - if self.volume_level is None: - return - volume = round(self.volume_level * MAX_VOLUME) - self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME)) - - def volume_down(self) -> None: - """Volume down media player.""" - if self.volume_level is None: - return - volume = round(self.volume_level * MAX_VOLUME) - self._monoprice.set_volume(self._zone_id, max(volume - 1, 0)) From 7fa55ffdd29af9d428b0ebd06b59b7be16130e3a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 00:10:31 +0100 Subject: [PATCH 412/927] Set volume_step in bluesound media_player (#105672) --- .../components/bluesound/media_player.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index eba03963ebc..cfe2fedebdc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -200,6 +200,7 @@ class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC + _attr_volume_step = 0.01 def __init__(self, hass, host, port=None, name=None, init_callback=None): """Initialize the media player.""" @@ -1027,20 +1028,6 @@ class BluesoundPlayer(MediaPlayerEntity): return await self.send_bluesound_command(url) - async def async_volume_up(self) -> None: - """Volume up the media player.""" - current_vol = self.volume_level - if not current_vol or current_vol >= 1: - return - return await self.async_set_volume_level(current_vol + 0.01) - - async def async_volume_down(self) -> None: - """Volume down the media player.""" - current_vol = self.volume_level - if not current_vol or current_vol <= 0: - return - return await self.async_set_volume_level(current_vol - 0.01) - async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" if volume < 0: From bb8dce6187b93ea17bf04902574b9c133a887e05 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 00:48:02 +0100 Subject: [PATCH 413/927] Set volume_step in aquostv media_player (#105665) --- .../components/aquostv/media_player.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 34d5e4161fb..cd93ddf9e15 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -112,6 +112,7 @@ class SharpAquosTVDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.PLAY ) + _attr_volume_step = 2 / 60 def __init__( self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False @@ -156,22 +157,6 @@ class SharpAquosTVDevice(MediaPlayerEntity): """Turn off tvplayer.""" self._remote.power(0) - @_retry - def volume_up(self) -> None: - """Volume up the media player.""" - if self.volume_level is None: - _LOGGER.debug("Unknown volume in volume_up") - return - self._remote.volume(int(self.volume_level * 60) + 2) - - @_retry - def volume_down(self) -> None: - """Volume down media player.""" - if self.volume_level is None: - _LOGGER.debug("Unknown volume in volume_down") - return - self._remote.volume(int(self.volume_level * 60) - 2) - @_retry def set_volume_level(self, volume: float) -> None: """Set Volume media player.""" From 36eeb15feedac126b3465d3c522a858a9cc9ac2e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 00:49:30 +0100 Subject: [PATCH 414/927] Set volume_step in clementine media_player (#105666) --- homeassistant/components/clementine/media_player.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 770f19e9970..eb0da23d360 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -65,6 +65,7 @@ class ClementineDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY ) + _attr_volume_step = 4 / 100 def __init__(self, client, name): """Initialize the Clementine device.""" @@ -123,16 +124,6 @@ class ClementineDevice(MediaPlayerEntity): return None, None - def volume_up(self) -> None: - """Volume up the media player.""" - newvolume = min(self._client.volume + 4, 100) - self._client.set_volume(newvolume) - - def volume_down(self) -> None: - """Volume down media player.""" - newvolume = max(self._client.volume - 4, 0) - self._client.set_volume(newvolume) - def mute_volume(self, mute: bool) -> None: """Send mute command.""" self._client.set_volume(0) From c10b460c6bf71cb0329dca991b7a09fc5cd963c4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 00:52:52 +0100 Subject: [PATCH 415/927] Set volume_step in cmus media_player (#105667) --- homeassistant/components/cmus/media_player.py | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index 65bfef3a0cb..a242a5a772c 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -94,6 +94,7 @@ class CmusDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.PLAY ) + _attr_volume_step = 5 / 100 def __init__(self, device, name, server): """Initialize the CMUS device.""" @@ -153,30 +154,6 @@ class CmusDevice(MediaPlayerEntity): """Set volume level, range 0..1.""" self._remote.cmus.set_volume(int(volume * 100)) - def volume_up(self) -> None: - """Set the volume up.""" - left = self.status["set"].get("vol_left") - right = self.status["set"].get("vol_right") - if left != right: - current_volume = float(left + right) / 2 - else: - current_volume = left - - if current_volume <= 100: - self._remote.cmus.set_volume(int(current_volume) + 5) - - def volume_down(self) -> None: - """Set the volume down.""" - left = self.status["set"].get("vol_left") - right = self.status["set"].get("vol_right") - if left != right: - current_volume = float(left + right) / 2 - else: - current_volume = left - - if current_volume <= 100: - self._remote.cmus.set_volume(int(current_volume) - 5) - def play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: From a2b31a06e3f6421418c570d400f980af7fb78f93 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Dec 2023 08:10:35 +0100 Subject: [PATCH 416/927] Avoid mutating entity descriptions in solaredge tests (#105718) --- tests/components/solaredge/test_coordinator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 550040a9b25..de9aab016ee 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,6 +2,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.solaredge.const import ( CONF_SITE_ID, @@ -9,7 +10,6 @@ from homeassistant.components.solaredge.const import ( DOMAIN, OVERVIEW_UPDATE_DELAY, ) -from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -19,6 +19,11 @@ SITE_ID = "1a2b3c4d5e6f7g8h" API_KEY = "a1b2c3d4e5f6g7h8" +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + @patch("homeassistant.components.solaredge.Solaredge") async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory @@ -31,8 +36,6 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( ) mock_solaredge().get_details.return_value = {"details": {"status": "active"}} mock_config_entry.add_to_hass(hass) - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await hass.config_entries.async_setup(mock_config_entry.entry_id) From 3f2fc2fce94c5cab71484f31e4c5a63cccee42e1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 15 Dec 2023 08:15:10 +0100 Subject: [PATCH 417/927] Fix mqtt tests modifying globals (#105774) --- tests/components/mqtt/test_cover.py | 3 ++- tests/components/mqtt/test_select.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 8db1c89bc40..c1732003fc0 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +from copy import deepcopy from typing import Any from unittest.mock import patch @@ -3582,7 +3583,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = cover.DOMAIN - config = DEFAULT_CONFIG + config = deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["position_topic"] = "some-position-topic" await help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 0c18881d86e..030f5a2ac9a 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -707,7 +707,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN - config = DEFAULT_CONFIG + config = copy.deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["options"] = ["milk", "beer"] await help_test_publishing_with_custom_encoding( From ef5d9d7377fd5cbfc9b17875ab17ff617ceb50b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Dec 2023 08:56:26 +0100 Subject: [PATCH 418/927] Partly migrate Plex to has entity name (#98841) * Partly migrate Plex to has entity name * Use friendly name for device name * Lowercase --- homeassistant/components/plex/button.py | 19 +++++++++---------- homeassistant/components/plex/sensor.py | 14 ++++++-------- homeassistant/components/plex/strings.json | 7 +++++++ tests/components/plex/test_button.py | 2 +- tests/components/plex/test_init.py | 8 ++++---- tests/components/plex/test_sensor.py | 2 +- tests/components/plex/test_server.py | 12 ++++++------ 7 files changed, 34 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 985b4ccb4e9..24bc09bac42 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -9,12 +9,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_SERVER, - CONF_SERVER_IDENTIFIER, - DOMAIN, - PLEX_UPDATE_PLATFORMS_SIGNAL, -) +from . import PlexServer +from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL +from .helpers import get_plex_server async def async_setup_entry( @@ -24,22 +21,24 @@ async def async_setup_entry( ) -> None: """Set up Plex button from config entry.""" server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER] - server_name: str = config_entry.data[CONF_SERVER] - async_add_entities([PlexScanClientsButton(server_id, server_name)]) + plex_server = get_plex_server(hass, server_id) + async_add_entities([PlexScanClientsButton(server_id, plex_server)]) class PlexScanClientsButton(ButtonEntity): """Representation of a scan_clients button entity.""" _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_translation_key = "scan_clients" - def __init__(self, server_id: str, server_name: str) -> None: + def __init__(self, server_id: str, plex_server: PlexServer) -> None: """Initialize a scan_clients Plex button entity.""" self.server_id = server_id - self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, server_id)}, + name=plex_server.friendly_name, manufacturer="Plex", ) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 972cd8d4bc9..acc309ab14c 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -17,7 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_SERVER_IDENTIFIER, DOMAIN, - NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, ) @@ -71,13 +70,15 @@ async def async_setup_entry( class PlexSensor(SensorEntity): """Representation of a Plex now playing sensor.""" + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:plex" + _attr_should_poll = False + _attr_native_unit_of_measurement = "watching" + def __init__(self, hass, plex_server): """Initialize the sensor.""" - self._attr_icon = "mdi:plex" - self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) - self._attr_should_poll = False self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" - self._attr_native_unit_of_measurement = "Watching" self._server = plex_server self.async_refresh_sensor = Debouncer( @@ -113,9 +114,6 @@ class PlexSensor(SensorEntity): @property def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" - if self.unique_id is None: - return None - return DeviceInfo( identifiers={(DOMAIN, self._server.machine_identifier)}, manufacturer="Plex", diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 9cba83653fd..4f5ca3f2bc4 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -57,6 +57,13 @@ } } }, + "entity": { + "button": { + "scan_clients": { + "name": "Scan clients" + } + } + }, "services": { "refresh_library": { "name": "Refresh library", diff --git a/tests/components/plex/test_button.py b/tests/components/plex/test_button.py index e8e734143b3..a37a3ea2df2 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -30,7 +30,7 @@ async def test_scan_clients_button_schedule( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.scan_clients_plex_server_1", + ATTR_ENTITY_ID: "button.plex_server_1_scan_clients", }, True, ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 6e1043b5c52..e12759b8a1f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -120,7 +120,7 @@ async def test_setup_with_photo_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -142,7 +142,7 @@ async def test_setup_with_live_tv_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -164,7 +164,7 @@ async def test_setup_with_transient_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -186,7 +186,7 @@ async def test_setup_with_unknown_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 5b9729792f4..93014dfedd1 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -110,7 +110,7 @@ async def test_library_sensor_values( mock_plex_server = await setup_plex_server() await wait_for_debouncer(hass) - activity_sensor = hass.states.get("sensor.plex_plex_server_1") + activity_sensor = hass.states.get("sensor.plex_server_1") assert activity_sensor.state == "1" # Ensure sensor is created as disabled diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index 1041caa298f..511025988ed 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -87,7 +87,7 @@ async def test_new_ignored_users_available( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) @@ -101,7 +101,7 @@ async def test_network_error_during_refresh( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -126,7 +126,7 @@ async def test_gdm_client_failure( active_sessions = mock_plex_server._plex_server.sessions() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -146,7 +146,7 @@ async def test_mark_sessions_idle( active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) url = mock_plex_server.url_in_use @@ -157,7 +157,7 @@ async def test_mark_sessions_idle( await hass.async_block_till_done() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -175,7 +175,7 @@ async def test_ignore_plex_web_client( await wait_for_debouncer(hass) active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) media_players = hass.states.async_entity_ids("media_player") From b344ac42a862d9d4386b1f2f1e4adca80cb08afb Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 15 Dec 2023 04:38:38 -0800 Subject: [PATCH 419/927] Bump opower to 0.0.41 (#105791) --- 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 d3a5928150e..2dba85c9469 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.40"] + "requirements": ["opower==0.0.41"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2584e5db3b9..62c14538757 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1423,7 +1423,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.40 +opower==0.0.41 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79a4aca339f..b7487e59740 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1099,7 +1099,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.40 +opower==0.0.41 # homeassistant.components.oralb oralb-ble==0.17.6 From af91a54663dc72f2a5f9e9c3d1e7d90d8e91d044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 15 Dec 2023 16:27:00 +0100 Subject: [PATCH 420/927] Bump aioairzone to v0.7.0 (#105807) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index e9485f1b9d0..893316b5564 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.9"] + "requirements": ["aioairzone==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62c14538757..c3bcc97564d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aioairq==0.3.1 aioairzone-cloud==0.3.6 # homeassistant.components.airzone -aioairzone==0.6.9 +aioairzone==0.7.0 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7487e59740..528d569ab75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.1 aioairzone-cloud==0.3.6 # homeassistant.components.airzone -aioairzone==0.6.9 +aioairzone==0.7.0 # homeassistant.components.ambient_station aioambient==2023.04.0 From b4741c2069cc3aab6d627774cb19882f565a2002 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 15 Dec 2023 16:30:54 +0100 Subject: [PATCH 421/927] Add data descriptions to MQTT config flow. (#105301) * Add data descriptions to MQTT config flow. * Remove keep allive, correct text CA cert --- homeassistant/components/mqtt/strings.json | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index f35cd7c2b58..82b6a50df31 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -51,6 +51,24 @@ "transport": "MQTT transport", "ws_headers": "WebSocket headers in JSON format", "ws_path": "WebSocket path" + }, + "data_description": { + "broker": "The hostname or IP address of your MQTT broker.", + "port": "The port your MQTT broker listens to. For example 1883.", + "username": "The username to login to your MQTT broker.", + "password": "The password to login to your MQTT broker.", + "advanced_options": "Enable and click `next` to set advanced options.", + "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", + "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", + "client_cert": "The client certificate to authenticate against your MQTT broker.", + "client_key": "The private key file that belongs to your client certificate.", + "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", + "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", + "set_ca_cert": "Select `Auto` for automatic CA validation or `Custom` and click `next` to set a custom CA certificate to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and click `next` to set a client certifificate and private ket to authenticate against your MQTT broker.", + "transport": "The transport to use for the connextion to your MQTT broker.", + "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", + "ws_path": "The WebSocket path to use for the connection to your MQTT broker." } }, "hassio_confirm": { @@ -58,6 +76,9 @@ "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?", "data": { "discovery": "Enable discovery" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery." } } }, @@ -123,6 +144,24 @@ "transport": "[%key:component::mqtt::config::step::broker::data::transport%]", "ws_headers": "[%key:component::mqtt::config::step::broker::data::ws_headers%]", "ws_path": "[%key:component::mqtt::config::step::broker::data::ws_path%]" + }, + "data_description": { + "broker": "[%key:component::mqtt::config::step::broker::data_description::broker%]", + "port": "[%key:component::mqtt::config::step::broker::data_description::port%]", + "username": "[%key:component::mqtt::config::step::broker::data_description::username%]", + "password": "[%key:component::mqtt::config::step::broker::data_description::password%]", + "advanced_options": "[%key:component::mqtt::config::step::broker::data_description::advanced_options%]", + "certificate": "[%key:component::mqtt::config::step::broker::data_description::certificate%]", + "client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]", + "client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]", + "client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]", + "tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]", + "protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]", + "set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]", + "set_client_cert": "[%key:component::mqtt::config::step::broker::data_description::set_client_cert%]", + "transport": "[%key:component::mqtt::config::step::broker::data_description::transport%]", + "ws_headers": "[%key:component::mqtt::config::step::broker::data_description::ws_headers%]", + "ws_path": "[%key:component::mqtt::config::step::broker::data_description::ws_path%]" } }, "options": { @@ -141,6 +180,20 @@ "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery.", + "discovery_prefix": "The prefix of configuration topics the MQTT interation will subscribe to.", + "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", + "birth_topic": "The MQTT topic where Home Assistant will publish a birth message.", + "birth_payload": "The birth message that is published when MQTT is ready and connected.", + "birth_qos": "The quality of service of the birth message that is published when MQTT is ready and connected", + "birth_retain": "When set, Home Assistant will retain the birth message published to your MQTT broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a will message when MQTT is stopped or looses the connection to your broker.", + "will_topic": "The MQTT topic your MQTT broker will publish a will message to.", + "will_payload": "The message your MQTT broker will publish when the connection is lost.", + "will_qos": "The quality of service of the will message that is published by your MQTT broker.", + "will_retain": "When set, your MQTT broker will retain the will message." } } }, From 67a30d71e6934910ba4b66b61fc51cd701975e3b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 15 Dec 2023 18:14:53 +0100 Subject: [PATCH 422/927] Fix HVAC mode duplication for Shelly Gen2 climate platform (#105812) Fix HVAC mode duplication --- homeassistant/components/shelly/climate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 9ac603a7fb0..7cc0027bbaf 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -420,7 +420,6 @@ class BlockSleepingClimate( class RpcClimate(ShellyRpcEntity, ClimateEntity): """Entity that controls a thermostat on RPC based Shelly devices.""" - _attr_hvac_modes = [HVACMode.OFF] _attr_icon = "mdi:thermostat" _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] @@ -436,9 +435,9 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): "type", "heating" ) if self._thermostat_type == "cooling": - self._attr_hvac_modes.append(HVACMode.COOL) + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL] else: - self._attr_hvac_modes.append(HVACMode.HEAT) + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] @property def target_temperature(self) -> float | None: From e02f4c9c60f7458e70943fdadd6125b377c7c076 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 16 Dec 2023 07:25:51 +1000 Subject: [PATCH 423/927] Fix translation keys in Tessie (#105824) --- .../components/tessie/coordinator.py | 2 +- homeassistant/components/tessie/entity.py | 6 +- homeassistant/components/tessie/sensor.py | 38 +++++----- homeassistant/components/tessie/strings.json | 70 +++++++++---------- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 7a2a8c71c56..397d9cb4dfc 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -79,7 +79,7 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): result = {} for key, value in data.items(): if parent: - key = f"{parent}-{key}" + key = f"{parent}_{key}" if isinstance(value, dict): result.update(self._flattern(value, key)) else: diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 4a14522a64c..361608cc73e 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -25,7 +25,7 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): self.vin = coordinator.vin self.key = key - car_type = coordinator.data["vehicle_config-car_type"] + car_type = coordinator.data["vehicle_config_car_type"] self._attr_translation_key = key self._attr_unique_id = f"{self.vin}-{key}" @@ -35,8 +35,8 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): configuration_url="https://my.tessie.com/", name=coordinator.data["display_name"], model=MODELS.get(car_type, car_type), - sw_version=coordinator.data["vehicle_state-car_version"], - hw_version=coordinator.data["vehicle_config-driver_assist"], + sw_version=coordinator.data["vehicle_state_car_version"], + hw_version=coordinator.data["vehicle_config_driver_assist"], ) @property diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 1941d8ba162..1c83370330b 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -48,74 +48,74 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, ), TessieSensorEntityDescription( - key="charge_state-usable_battery_level", + key="charge_state_usable_battery_level", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), TessieSensorEntityDescription( - key="charge_state-charge_energy_added", + key="charge_state_charge_energy_added", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, ), TessieSensorEntityDescription( - key="charge_state-charger_power", + key="charge_state_charger_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), TessieSensorEntityDescription( - key="charge_state-charger_voltage", + key="charge_state_charger_voltage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="charge_state-charger_actual_current", + key="charge_state_charger_actual_current", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="charge_state-charge_rate", + key="charge_state_charge_rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="charge_state-battery_range", + key="charge_state_battery_range", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), TessieSensorEntityDescription( - key="drive_state-speed", + key="drive_state_speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, ), TessieSensorEntityDescription( - key="drive_state-power", + key="drive_state_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="drive_state-shift_state", + key="drive_state_shift_state", icon="mdi:car-shift-pattern", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, value_fn=lambda x: x.lower() if isinstance(x, str) else x, ), TessieSensorEntityDescription( - key="vehicle_state-odometer", + key="vehicle_state_odometer", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -123,7 +123,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="vehicle_state-tpms_pressure_fl", + key="vehicle_state_tpms_pressure_fl", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -132,7 +132,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="vehicle_state-tpms_pressure_fr", + key="vehicle_state_tpms_pressure_fr", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -141,7 +141,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="vehicle_state-tpms_pressure_rl", + key="vehicle_state_tpms_pressure_rl", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -150,7 +150,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="vehicle_state-tpms_pressure_rr", + key="vehicle_state_tpms_pressure_rr", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -159,21 +159,21 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="climate_state-inside_temp", + key="climate_state_inside_temp", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), TessieSensorEntityDescription( - key="climate_state-outside_temp", + key="climate_state_outside_temp", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), TessieSensorEntityDescription( - key="climate_state-driver_temp_setting", + key="climate_state_driver_temp_setting", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -181,7 +181,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TessieSensorEntityDescription( - key="climate_state-passenger_temp_setting", + key="climate_state_passenger_temp_setting", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 5d57075241c..84ca54286f2 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -31,35 +31,35 @@ "offline": "Offline" } }, - "charge_state-usable_battery_level": { - "name": "Battery Level" + "charge_state_usable_battery_level": { + "name": "Battery level" }, - "charge_state-charge_energy_added": { - "name": "Charge Energy Added" + "charge_state_charge_energy_added": { + "name": "Charge energy added" }, - "charge_state-charger_power": { - "name": "Charger Power" + "charge_state_charger_power": { + "name": "Charger power" }, - "charge_state-charger_voltage": { - "name": "Charger Voltage" + "charge_state_charger_voltage": { + "name": "Charger voltage" }, - "charge_state-charger_actual_current": { - "name": "Charger Current" + "charge_state_charger_actual_current": { + "name": "Charger current" }, - "charge_state-charge_rate": { - "name": "Charge Rate" + "charge_state_charge_rate": { + "name": "Charge rate" }, - "charge_state-battery_range": { - "name": "Battery Range" + "charge_state_battery_range": { + "name": "Battery range" }, - "drive_state-speed": { + "drive_state_speed": { "name": "Speed" }, - "drive_state-power": { + "drive_state_power": { "name": "Power" }, - "drive_state-shift_state": { - "name": "Shift State", + "drive_state_shift_state": { + "name": "Shift state", "state": { "p": "Park", "d": "Drive", @@ -67,32 +67,32 @@ "n": "Neutral" } }, - "vehicle_state-odometer": { + "vehicle_state_odometer": { "name": "Odometer" }, - "vehicle_state-tpms_pressure_fl": { - "name": "Tyre Pressure Front Left" + "vehicle_state_tpms_pressure_fl": { + "name": "Tyre pressure front left" }, - "vehicle_state-tpms_pressure_fr": { - "name": "Tyre Pressure Front Right" + "vehicle_state_tpms_pressure_fr": { + "name": "Tyre pressure front right" }, - "vehicle_state-tpms_pressure_rl": { - "name": "Tyre Pressure Rear Left" + "vehicle_state_tpms_pressure_rl": { + "name": "Tyre pressure rear left" }, - "vehicle_state-tpms_pressure_rr": { - "name": "Tyre Pressure Rear Right" + "vehicle_state_tpms_pressure_rr": { + "name": "Tyre pressure rear right" }, - "climate_state-inside_temp": { - "name": "Inside Temperature" + "climate_state_inside_temp": { + "name": "Inside temperature" }, - "climate_state-outside_temp": { - "name": "Outside Temperature" + "climate_state_outside_temp": { + "name": "Outside temperature" }, - "climate_state-driver_temp_setting": { - "name": "Driver Temperature Setting" + "climate_state_driver_temp_setting": { + "name": "Driver temperature setting" }, - "climate_state-passenger_temp_setting": { - "name": "Passenger Temperature Setting" + "climate_state_passenger_temp_setting": { + "name": "Passenger temperature setting" } } } From a12c490cff2fb73c376a87593abfab5165d368ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 16 Dec 2023 01:59:43 +0100 Subject: [PATCH 424/927] Update aioairzone to v0.7.2 (#105811) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 893316b5564..20b8a452324 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.7.0"] + "requirements": ["aioairzone==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3bcc97564d..e7beac5ea33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aioairq==0.3.1 aioairzone-cloud==0.3.6 # homeassistant.components.airzone -aioairzone==0.7.0 +aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 528d569ab75..96954daf684 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.1 aioairzone-cloud==0.3.6 # homeassistant.components.airzone -aioairzone==0.7.0 +aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 8a8573689fa..3dcdde52390 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -602,7 +602,7 @@ 1, ]), 'demand': False, - 'double-set-point': True, + 'double-set-point': False, 'full-name': 'Airzone [2:1] Airzone 2:1', 'heat-stage': 1, 'heat-stages': list([ From 59630460c67aa66ac7b8cf83be54dc6ded3d6c86 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 16 Dec 2023 12:11:20 +1000 Subject: [PATCH 425/927] Quality fixes for Tessie (#105838) --- homeassistant/components/tessie/entity.py | 2 +- homeassistant/components/tessie/sensor.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 361608cc73e..a0263467ac2 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -40,6 +40,6 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): ) @property - def value(self) -> Any: + def _value(self) -> Any: """Return value from coordinator data.""" return self.coordinator.data[self.key] diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 1c83370330b..2836b7e3931 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -31,8 +31,6 @@ from .const import DOMAIN, TessieStatus from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity -PARALLEL_UPDATES = 0 - @dataclass class TessieSensorEntityDescription(SensorEntityDescription): @@ -222,4 +220,4 @@ class TessieSensorEntity(TessieEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.value) + return self.entity_description.value_fn(self._value) From 887f9a21e50029026536650a8f80e28dbf785021 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 16 Dec 2023 14:56:38 +1000 Subject: [PATCH 426/927] Add Binary Sensor platform to Tessie (#105421) --- homeassistant/components/tessie/__init__.py | 2 +- .../components/tessie/binary_sensor.py | 142 ++++++++++++++++++ homeassistant/components/tessie/strings.json | 64 +++++++- tests/components/tessie/common.py | 1 + .../components/tessie/test_binary_sensors.py | 33 ++++ 5 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/tessie/binary_sensor.py create mode 100644 tests/components/tessie/test_binary_sensors.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index ac77c3cc09e..a1553aa0c7e 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py new file mode 100644 index 00000000000..ca78b19a42b --- /dev/null +++ b/homeassistant/components/tessie/binary_sensor.py @@ -0,0 +1,142 @@ +"""Binary Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +@dataclass +class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Tessie binary sensor entity.""" + + is_on: Callable[..., bool] = lambda x: x + + +DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( + TessieBinarySensorEntityDescription( + key="charge_state_battery_heater_on", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_charging_state", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on=lambda x: x == "Charging", + ), + TessieBinarySensorEntityDescription( + key="charge_state_preconditioning_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_scheduled_charging_pending", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_trip_charging", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_left", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_right", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_steering_wheel_heat", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "On", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection_actively_cooling", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_dashcam_state", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "Recording", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_is_user_present", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie binary sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieBinarySensorEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TessieBinarySensorEntity(TessieEntity, BinarySensorEntity): + """Base class for Tessie binary sensors.""" + + entity_description: TessieBinarySensorEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.is_on(self._value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 84ca54286f2..8785f2aadc3 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -71,16 +71,16 @@ "name": "Odometer" }, "vehicle_state_tpms_pressure_fl": { - "name": "Tyre pressure front left" + "name": "Tire pressure front left" }, "vehicle_state_tpms_pressure_fr": { - "name": "Tyre pressure front right" + "name": "Tire pressure front right" }, "vehicle_state_tpms_pressure_rl": { - "name": "Tyre pressure rear left" + "name": "Tire pressure rear left" }, "vehicle_state_tpms_pressure_rr": { - "name": "Tyre pressure rear right" + "name": "Tire pressure rear right" }, "climate_state_inside_temp": { "name": "Inside temperature" @@ -94,6 +94,62 @@ "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" } + }, + "binary_sensor": { + "charge_state_battery_heater_on": { + "name": "Battery heater" + }, + "charge_state_charge_enable_request": { + "name": "Charge enable request" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "charge_state_charging_state": { + "name": "Charging" + }, + "charge_state_preconditioning_enabled": { + "name": "Preconditioning enabled" + }, + "charge_state_scheduled_charging_pending": { + "name": "Scheduled charging pending" + }, + "charge_state_trip_charging": { + "name": "Trip charging" + }, + "climate_state_auto_seat_climate_left": { + "name": "Auto seat climate left" + }, + "climate_state_auto_seat_climate_right": { + "name": "Auto seat climate right" + }, + "climate_state_auto_steering_wheel_heater": { + "name": "Auto steering wheel heater" + }, + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, + "climate_state_cabin_overheat_protection_actively_cooling": { + "name": "Cabin overheat protection actively cooling" + }, + "vehicle_state_dashcam_state": { + "name": "Dashcam" + }, + "vehicle_state_is_user_present": { + "name": "User present" + }, + "vehicle_state_tpms_soft_warning_fl": { + "name": "Tire pressure warning front left" + }, + "vehicle_state_tpms_soft_warning_fr": { + "name": "Tire pressure warning front right" + }, + "vehicle_state_tpms_soft_warning_rl": { + "name": "Tire pressure warning rear left" + }, + "vehicle_state_tpms_soft_warning_rr": { + "name": "Tire pressure warning rear right" + } } } } diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 572a687a6e5..30b6feca4d7 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) +TEST_RESPONSE = {"result": True} TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} TESSIE_URL = "https://api.tessie.com/" diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py new file mode 100644 index 00000000000..594b270ec7a --- /dev/null +++ b/tests/components/tessie/test_binary_sensors.py @@ -0,0 +1,33 @@ +"""Test the Tessie binary sensor platform.""" + +from homeassistant.components.tessie.binary_sensor import DESCRIPTIONS +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + +OFFON = [STATE_OFF, STATE_ON] + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Tests that the sensors are correct.""" + + assert len(hass.states.async_all("binary_sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("binary_sensor")) == len(DESCRIPTIONS) + + state = hass.states.get("binary_sensor.test_battery_heater").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_heater_on"] + + state = hass.states.get("binary_sensor.test_charging").state + is_on = state == STATE_ON + assert is_on == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charging_state"] == "Charging" + ) + + state = hass.states.get("binary_sensor.test_auto_seat_climate_left").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["climate_state"]["auto_seat_climate_left"] From 19341863ba718e10e921bbfefa151a57b4c86340 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 21:02:00 -1000 Subject: [PATCH 427/927] Bump pyunifiprotect to 4.22.3 (#105833) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.22.0...v4.22.3 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 045538aa2d1..cd38f50bf6d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.3", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index e7beac5ea33..e41786ebe18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,7 +2264,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.0 +pyunifiprotect==4.22.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96954daf684..9c5fcc306f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,7 +1703,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.0 +pyunifiprotect==4.22.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 9c134c6b5106485b81292c85a456d9dfbf4faa65 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 16 Dec 2023 01:06:04 -0600 Subject: [PATCH 428/927] Bump soco to 0.30.0 (#105823) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5d36da862ca..0e1a1d7daa4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"], + "requirements": ["soco==0.30.0", "sonos-websocket==0.1.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index e41786ebe18..ac326d767b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2487,7 +2487,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c5fcc306f8..8fd36e8c6a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge solaredge==0.0.2 From 1271f16385288adec3573219a1e181067152d9d6 Mon Sep 17 00:00:00 2001 From: Joseph Block Date: Sat, 16 Dec 2023 02:38:21 -0500 Subject: [PATCH 429/927] Add Unifi device state for additional diagnostics (#105138) * Add device state for additional diagnostics * Add state test and fix existing tests * Utilize IntEnum and dict for state lookup * Update aiounifi to v68 --- homeassistant/components/unifi/const.py | 18 +++++++ homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/sensor.py | 26 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_sensor.py | 52 ++++++++++++++++++-- tests/components/unifi/test_update.py | 2 +- 7 files changed, 95 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index c78313f66e2..2b16895a9a8 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -2,6 +2,8 @@ import logging +from aiounifi.models.device import DeviceState + from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -46,3 +48,19 @@ ATTR_MANUFACTURER = "Ubiquiti Networks" BLOCK_SWITCH = "block" DPI_SWITCH = "dpi" OUTLET_SWITCH = "outlet" + +DEVICE_STATES = { + DeviceState.DISCONNECTED: "Disconnected", + DeviceState.CONNECTED: "Connected", + DeviceState.PENDING: "Pending", + DeviceState.FIRMWARE_MISMATCH: "Firmware Mismatch", + DeviceState.UPGRADING: "Upgrading", + DeviceState.PROVISIONING: "Provisioning", + DeviceState.HEARTBEAT_MISSED: "Heartbeat Missed", + DeviceState.ADOPTING: "Adopting", + DeviceState.DELETING: "Deleting", + DeviceState.INFORM_ERROR: "Inform Error", + DeviceState.ADOPTION_FALIED: "Adoption Failed", + DeviceState.ISOLATED: "Isolated", + DeviceState.UNKNOWN: "Unknown", +} diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7d4717d3fff..4a43a65d5bb 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==67"], + "requirements": ["aiounifi==68"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 4d5cf49b5c9..cc825ea51af 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -36,6 +36,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util +from .const import DEVICE_STATES from .controller import UniFiController from .entity import ( HandlerT, @@ -138,6 +139,12 @@ class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] +@callback +def async_device_state_value_fn(controller: UniFiController, device: Device) -> str: + """Retrieve the state of the device.""" + return DEVICE_STATES[device.state] + + @dataclass class UnifiSensorEntityDescription( SensorEntityDescription, @@ -343,6 +350,25 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", value_fn=lambda ctrlr, device: device.general_temperature, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device State", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "State", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_state-{obj_id}", + value_fn=async_device_state_value_fn, + options=list(DEVICE_STATES.values()), + ), ) diff --git a/requirements_all.txt b/requirements_all.txt index ac326d767b9..e7b00cf42bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==67 +aiounifi==68 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fd36e8c6a6..119a926ed48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==67 +aiounifi==68 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 854d136f3dd..6eb6c05209c 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -3,6 +3,7 @@ from copy import deepcopy from datetime import datetime, timedelta from unittest.mock import patch +from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,6 +21,7 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DEVICE_STATES, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -584,7 +586,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -807,8 +809,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 10 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_all()) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -856,7 +858,7 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" ent_reg = er.async_get(hass) @@ -912,7 +914,7 @@ async def test_device_temperature( } await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 assert hass.states.get("sensor.device_temperature").state == "30" ent_reg = er.async_get(hass) @@ -925,3 +927,43 @@ async def test_device_temperature( device["general_temperature"] = 60 mock_unifi_websocket(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_temperature").state == "60" + + +async def test_device_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that state sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_state").entity_category + is EntityCategory.DIAGNOSTIC + ) + + for i in list(map(int, DeviceState)): + device["state"] = i + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 4f7a3dfe11d..a9fe3fdae7c 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -117,7 +117,7 @@ async def test_device_updates( # Simulate update finished - device_1["state"] = "0" + device_1["state"] = 0 device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] From 47f8e082615943471ed8ffb680f0bc564e85605e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 23:16:58 -1000 Subject: [PATCH 430/927] Reduce overhead to connect dispatcher (#105715) Reduce overhead connect dispatcher - We tend to have 1000s (or 10000s) of connected dispatchers which makes these prime targets to reduce overhead/memory - Instead of creating new functions to wrap log exceptions each time use partials which reuses the function body and only create new arguments Previous optimizations #103307 #93602 --- homeassistant/helpers/dispatcher.py | 20 +++++----- homeassistant/util/logging.py | 62 +++++++++++++++++------------ tests/helpers/test_dispatcher.py | 21 +++++++++- tests/util/test_logging.py | 45 ++++++++++++++++++++- 4 files changed, 110 insertions(+), 38 deletions(-) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index e416d939914..07112226ecf 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -90,20 +90,22 @@ def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) +def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: + """Format error message.""" + return "Exception in {} when dispatching '{}': {}".format( + # Functions wrapped in partial do not have a __name__ + getattr(target, "__name__", None) or str(target), + signal, + args, + ) + + def _generate_job( signal: str, target: Callable[..., Any] ) -> HassJob[..., None | Coroutine[Any, Any, None]]: """Generate a HassJob for a signal and target.""" return HassJob( - catch_log_exception( - target, - lambda *args: "Exception in {} when dispatching '{}': {}".format( - # Functions wrapped in partial do not have a __name__ - getattr(target, "__name__", None) or str(target), - signal, - args, - ), - ), + catch_log_exception(target, partial(_format_err, signal, target)), f"dispatcher {signal}", ) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1328e8ded60..07ff413a016 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -101,6 +101,39 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) +async def _async_wrapper( + async_func: Callable[..., Coroutine[Any, Any, None]], + format_err: Callable[..., Any], + *args: Any, +) -> None: + """Catch and log exception.""" + try: + await async_func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +def _sync_wrapper( + func: Callable[..., Any], format_err: Callable[..., Any], *args: Any +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +@callback +def _callback_wrapper( + func: Callable[..., Any], format_err: Callable[..., Any], *args: Any +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + @overload def catch_log_exception( func: Callable[..., Coroutine[Any, Any, Any]], format_err: Callable[..., Any] @@ -128,35 +161,14 @@ def catch_log_exception( while isinstance(check_func, partial): check_func = check_func.func - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] if asyncio.iscoroutinefunction(check_func): async_func = cast(Callable[..., Coroutine[Any, Any, None]], func) + return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) - @wraps(async_func) - async def async_wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - await async_func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) + if is_callback(check_func): + return wraps(func)(partial(_callback_wrapper, func, format_err)) - wrapper_func = async_wrapper - - else: - - @wraps(func) - def wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) - - if is_callback(check_func): - wrapper = callback(wrapper) - - wrapper_func = wrapper - return wrapper_func + return wraps(func)(partial(_sync_wrapper, func, format_err)) def catch_log_coro_exception( diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index a251b20b0f4..89d23fb4533 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -144,8 +144,6 @@ async def test_callback_exception_gets_logged( # wrap in partial to test message logging. async_dispatcher_connect(hass, "test", partial(bad_handler)) async_dispatcher_send(hass, "test", "bad") - await hass.async_block_till_done() - await hass.async_block_till_done() assert ( f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" @@ -153,6 +151,25 @@ async def test_callback_exception_gets_logged( ) +@pytest.mark.no_fail_on_log_exception +async def test_coro_exception_gets_logged( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test exception raised by signal handler.""" + + async def bad_async_handler(*args): + """Record calls.""" + raise Exception("This is a bad message in a coro") + + # wrap in partial to test message logging. + async_dispatcher_connect(hass, "test", bad_async_handler) + async_dispatcher_send(hass, "test", "bad") + await hass.async_block_till_done() + + assert "bad_async_handler" in caplog.text + assert "when dispatching 'test': ('bad',)" in caplog.text + + async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: """Test adding a dispatcher from a dispatcher.""" calls = [] diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index a08311cca4f..350baa9d4c2 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -7,7 +7,12 @@ from unittest.mock import patch import pytest -from homeassistant.core import HomeAssistant, callback, is_callback +from homeassistant.core import ( + HomeAssistant, + callback, + is_callback, + is_callback_check_partial, +) import homeassistant.util.logging as logging_util @@ -93,7 +98,7 @@ def test_catch_log_exception() -> None: def callback_meth(): pass - assert is_callback( + assert is_callback_check_partial( logging_util.catch_log_exception(partial(callback_meth), lambda: None) ) @@ -104,3 +109,39 @@ def test_catch_log_exception() -> None: assert not is_callback(wrapped) assert not asyncio.iscoroutinefunction(wrapped) + + +@pytest.mark.no_fail_on_log_exception +async def test_catch_log_exception_catches_and_logs() -> None: + """Test it is still a callback after wrapping including partial.""" + saved_args = [] + + def save_args(*args): + saved_args.append(args) + + async def async_meth(): + raise ValueError("failure async") + + func = logging_util.catch_log_exception(async_meth, save_args) + await func("failure async passed") + + assert saved_args == [("failure async passed",)] + saved_args.clear() + + @callback + def callback_meth(): + raise ValueError("failure callback") + + func = logging_util.catch_log_exception(callback_meth, save_args) + func("failure callback passed") + + assert saved_args == [("failure callback passed",)] + saved_args.clear() + + def sync_meth(): + raise ValueError("failure sync") + + func = logging_util.catch_log_exception(sync_meth, save_args) + func("failure sync passed") + + assert saved_args == [("failure sync passed",)] From 104bcc64b7da3195c24ed58f1ec60dff6195ee8d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 16 Dec 2023 10:33:50 +0100 Subject: [PATCH 431/927] Allow inheriting base component entity descriptions in frozen dataclasses (#105512) Co-authored-by: J. Nick Koston --- homeassistant/components/alarm_control_panel/__init__.py | 4 +--- homeassistant/components/binary_sensor/__init__.py | 4 +--- .../components/bluetooth/passive_update_processor.py | 3 +++ homeassistant/components/button/__init__.py | 4 +--- homeassistant/components/camera/__init__.py | 5 ++--- homeassistant/components/climate/__init__.py | 4 +--- homeassistant/components/cover/__init__.py | 4 +--- homeassistant/components/date/__init__.py | 4 +--- homeassistant/components/datetime/__init__.py | 4 +--- homeassistant/components/event/__init__.py | 3 +-- homeassistant/components/fan/__init__.py | 4 +--- homeassistant/components/humidifier/__init__.py | 4 +--- homeassistant/components/image/__init__.py | 3 +-- homeassistant/components/image_processing/__init__.py | 4 +--- homeassistant/components/lawn_mower/__init__.py | 4 +--- homeassistant/components/light/__init__.py | 3 +-- homeassistant/components/lock/__init__.py | 4 +--- homeassistant/components/media_player/__init__.py | 4 +--- homeassistant/components/number/__init__.py | 3 +-- homeassistant/components/remote/__init__.py | 4 +--- homeassistant/components/select/__init__.py | 4 +--- homeassistant/components/sensor/__init__.py | 3 +-- homeassistant/components/siren/__init__.py | 4 +--- homeassistant/components/switch/__init__.py | 4 +--- homeassistant/components/text/__init__.py | 3 +-- homeassistant/components/time/__init__.py | 4 +--- homeassistant/components/update/__init__.py | 4 +--- homeassistant/components/vacuum/__init__.py | 7 ++----- homeassistant/components/water_heater/__init__.py | 4 +--- homeassistant/components/weather/__init__.py | 4 +--- homeassistant/helpers/entity.py | 3 +-- 31 files changed, 35 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f3e02465c13..c307e96e9f0 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,7 +1,6 @@ """Component to interface with an alarm control panel.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Final, final @@ -121,8 +120,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class AlarmControlPanelEntityDescription(EntityDescription): +class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes alarm control panel entities.""" diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index a84cbc18756..a3303c525cb 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,7 +1,6 @@ """Component to interface with binary sensors.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -176,8 +175,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class BinarySensorEntityDescription(EntityDescription): +class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" device_class: BinarySensorDeviceClass | None = None diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index eeccf081b55..601f78d4c8d 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -93,7 +93,10 @@ def deserialize_entity_description( descriptions_class: type[EntityDescription], data: dict[str, Any] ) -> EntityDescription: """Deserialize an entity description.""" + # pylint: disable=protected-access result: dict[str, Any] = {} + if hasattr(descriptions_class, "_dataclass"): + descriptions_class = descriptions_class._dataclass for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 901acdcdec1..4ebe1df68a2 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,7 +1,6 @@ """Component to pressing a button as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timedelta from enum import StrEnum import logging @@ -73,8 +72,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ButtonEntityDescription(EntityDescription): +class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes button entities.""" device_class: ButtonDeviceClass | None = None diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index bb5a44a530c..528c2cef50a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -5,7 +5,7 @@ import asyncio import collections from collections.abc import Awaitable, Callable, Iterable from contextlib import suppress -from dataclasses import asdict, dataclass +from dataclasses import asdict from datetime import datetime, timedelta from enum import IntFlag from functools import partial @@ -132,8 +132,7 @@ CAMERA_SERVICE_RECORD: Final = { } -@dataclass -class CameraEntityDescription(EntityDescription): +class CameraEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes camera entities.""" diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a075467a313..3e93bf27ffc 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,7 +1,6 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -201,8 +200,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ClimateEntityDescription(EntityDescription): +class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes climate entities.""" diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 354b972e2b7..633300af591 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag, StrEnum import functools as ft @@ -212,8 +211,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class CoverEntityDescription(EntityDescription): +class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes cover entities.""" device_class: CoverDeviceClass | None = None diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 51f3a492c47..7426293cfb4 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -1,7 +1,6 @@ """Component to allow setting date as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import date, timedelta import logging from typing import final @@ -62,8 +61,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateEntityDescription(EntityDescription): +class DateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date entities.""" diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index e25f4535d0c..823028ee6a7 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -1,7 +1,6 @@ """Component to allow setting date/time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging from typing import final @@ -71,8 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateTimeEntityDescription(EntityDescription): +class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date/time entities.""" diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index d9608670972..40e55472d12 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -71,8 +71,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class EventEntityDescription(EntityDescription): +class EventEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes event entities.""" device_class: EventDeviceClass | None = None diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 21ffca35962..23261c4d944 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,7 +1,6 @@ """Provides functionality to interact with fans.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft @@ -187,8 +186,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class FanEntityDescription(ToggleEntityDescription): +class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes fan entities.""" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 47745c53394..39150126b7a 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,7 +1,6 @@ """Provides functionality to interact with humidifier devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -124,8 +123,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class HumidifierEntityDescription(ToggleEntityDescription): +class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes humidifier entities.""" device_class: HumidifierDeviceClass | None = None diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e5c40affe0f..d90295f6279 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -44,8 +44,7 @@ _RND: Final = SystemRandom() GET_IMAGE_TIMEOUT: Final = 10 -@dataclass -class ImageEntityDescription(EntityDescription): +class ImageEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes image entities.""" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7640925451a..916812e41c9 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -120,8 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclass -class ImageProcessingEntityDescription(EntityDescription): +class ImageProcessingEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: ImageProcessingDeviceClass | None = None diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 5388463316f..b25f9ab34af 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -1,7 +1,6 @@ """The lawn mower integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging from typing import final @@ -65,8 +64,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LawnMowerEntityEntityDescription(EntityDescription): +class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lawn mower entities.""" diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 3bb3797c284..6643884566f 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -816,8 +816,7 @@ class Profiles: params.setdefault(ATTR_TRANSITION, profile.transition) -@dataclasses.dataclass -class LightEntityDescription(ToggleEntityDescription): +class LightEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ca91236a77c..b28aa9d0a1b 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,7 +1,6 @@ """Component to interface with locks that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft @@ -101,8 +100,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LockEntityDescription(EntityDescription): +class LockEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lock entities.""" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2ca47b97275..a45127d7b86 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,7 +5,6 @@ import asyncio import collections from collections.abc import Callable from contextlib import suppress -from dataclasses import dataclass import datetime as dt from enum import StrEnum import functools as ft @@ -449,8 +448,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class MediaPlayerEntityDescription(EntityDescription): +class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes media player entities.""" device_class: MediaPlayerDeviceClass | None = None diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 201fa8fedb6..631fc5fc96c 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -120,8 +120,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclasses.dataclass -class NumberEntityDescription(EntityDescription): +class NumberEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes number entities.""" device_class: NumberDeviceClass | None = None diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 17915e1be19..2901c14c455 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft @@ -155,8 +154,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class RemoteEntityDescription(ToggleEntityDescription): +class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes remote entities.""" diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4997e088a54..9c978555dd5 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -1,7 +1,6 @@ """Component to allow selecting an option from a list as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -118,8 +117,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SelectEntityDescription(EntityDescription): +class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes select entities.""" options: list[str] | None = None diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 0fa270bb03d..9cdcfade9ec 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -136,8 +136,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SensorEntityDescription(EntityDescription): +class SensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: SensorDeviceClass | None = None diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index ac02201b928..d7e8843f54b 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -1,7 +1,6 @@ """Component to interface with various sirens/chimes.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, TypedDict, cast, final @@ -149,8 +148,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SirenEntityDescription(ToggleEntityDescription): +class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes siren entities.""" available_tones: list[int | str] | dict[int, str] | None = None diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bf3c3424142..bdbb2b7701b 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,7 +1,6 @@ """Component to interface with switches that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -89,8 +88,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SwitchEntityDescription(ToggleEntityDescription): +class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes switch entities.""" device_class: SwitchDeviceClass | None = None diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index acc5f62a0cc..8e20fdd33af 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -98,8 +98,7 @@ class TextMode(StrEnum): TEXT = "text" -@dataclass -class TextEntityDescription(EntityDescription): +class TextEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes text entities.""" native_min: int = 0 diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 26d40191fb9..2b5721aaf1b 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -1,7 +1,6 @@ """Component to allow setting time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import time, timedelta import logging from typing import final @@ -62,8 +61,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class TimeEntityDescription(EntityDescription): +class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes time entities.""" diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index c9496ce8f7b..8597647fc18 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -1,7 +1,6 @@ """Component to allow for providing device or service updates.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum from functools import lru_cache @@ -175,8 +174,7 @@ async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) - await entity.async_clear_skipped() -@dataclass -class UpdateEntityDescription(EntityDescription): +class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes update entities.""" device_class: UpdateDeviceClass | None = None diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index c0680913df6..5ffb3de2a12 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag from functools import partial @@ -367,8 +366,7 @@ class _BaseVacuum(Entity): ) -@dataclass -class VacuumEntityDescription(ToggleEntityDescription): +class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" @@ -490,8 +488,7 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) -@dataclass -class StateVacuumEntityDescription(EntityDescription): +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 9e796092f6a..6506be10065 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft @@ -156,8 +155,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WaterHeaterEntityEntityDescription(EntityDescription): +class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 3d9eccd9425..899181f2b5f 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -5,7 +5,6 @@ import abc import asyncio from collections.abc import Callable, Iterable from contextlib import suppress -from dataclasses import dataclass from datetime import timedelta from functools import partial import logging @@ -251,8 +250,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WeatherEntityDescription(EntityDescription): +class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes weather entities.""" diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index f19edaf28b9..9f5ff3dad52 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1313,8 +1313,7 @@ class Entity(ABC): ) -@dataclasses.dataclass(slots=True) -class ToggleEntityDescription(EntityDescription): +class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" From 80a164347a3c29323122256ca6a2acc589356d36 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:47:30 +0100 Subject: [PATCH 432/927] Update pre-commit to 3.6.0 (#105856) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9b30c0e40a1..f2b4f3b535f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.3.2 freezegun==1.3.1 mock-open==1.4.0 mypy==1.7.1 -pre-commit==3.5.0 +pre-commit==3.6.0 pydantic==1.10.12 pylint==3.0.3 pylint-per-file-ignores==1.2.1 From e7d7bb4f25b93e572c8c2b1e0cfdc7b2734d8eb8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:47:47 +0100 Subject: [PATCH 433/927] Update coverage to 7.3.3 (#105855) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index f2b4f3b535f..27e5bfaff6b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.2 +coverage==7.3.3 freezegun==1.3.1 mock-open==1.4.0 mypy==1.7.1 From 8549311d1ddc5bd3a5777169fcac44ef21020c5f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:48:51 +0100 Subject: [PATCH 434/927] Update feedparser to 6.0.11 (#105857) --- homeassistant/components/feedreader/manifest.json | 2 +- pyproject.toml | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json index 922bf5551ee..fe52dc4d4c2 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/feedreader", "iot_class": "cloud_polling", "loggers": ["feedparser", "sgmllib3k"], - "requirements": ["feedparser==6.0.10"] + "requirements": ["feedparser==6.0.11"] } diff --git a/pyproject.toml b/pyproject.toml index b30e611d4a1..2e992da0ab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -481,8 +481,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", - # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 diff --git a/requirements_all.txt b/requirements_all.txt index e7b00cf42bf..b60f455b3ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -808,7 +808,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.10 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 119a926ed48..d92ab48c98b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -643,7 +643,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.10 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 From 755dcd8bc67eb550d3d32e074a42dcd66b7392a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Dec 2023 06:09:03 -1000 Subject: [PATCH 435/927] Ensure bluetooth auto recovery does not run in tests (#105841) --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1e70ad48065..1948b001ad4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1545,7 +1545,7 @@ async def mock_enable_bluetooth( @pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" - with patch( + with patch("bluetooth_auto_recovery.recover_adapter"), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux" ), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", From 3be12c061137e8ed3f231e918d55d52157c55a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 16 Dec 2023 18:42:58 +0100 Subject: [PATCH 436/927] Fix Airzone temperature range on new climate card (#105830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: climate: fix double setpoint Signed-off-by: Álvaro Fernández Rojas * tests: airzone: fix double setpoint temperature Signed-off-by: Álvaro Fernández Rojas * tests: airzone: fix swapped double setpoint Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 3 ++- .../airzone/snapshots/test_diagnostics.ambr | 8 ++++---- tests/components/airzone/test_climate.py | 11 ++++++----- tests/components/airzone/util.py | 4 ++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 22172255b9b..f5a0e1b109e 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -248,7 +248,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.OFF self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) - self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -258,3 +257,5 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_target_temperature_low = self.get_airzone_value( AZD_HEAT_TEMP_SET ) + else: + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 3dcdde52390..adf0176765c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -188,7 +188,7 @@ 'coldStages': 0, 'coolmaxtemp': 90, 'coolmintemp': 64, - 'coolsetpoint': 73, + 'coolsetpoint': 77, 'errors': list([ ]), 'floor_demand': 0, @@ -196,7 +196,7 @@ 'heatStages': 0, 'heatmaxtemp': 86, 'heatmintemp': 50, - 'heatsetpoint': 77, + 'heatsetpoint': 73, 'humidity': 0, 'maxTemp': 90, 'minTemp': 64, @@ -645,7 +645,7 @@ 'cold-stage': 0, 'cool-temp-max': 90.0, 'cool-temp-min': 64.0, - 'cool-temp-set': 73.0, + 'cool-temp-set': 77.0, 'demand': True, 'double-set-point': True, 'floor-demand': False, @@ -653,7 +653,7 @@ 'heat-stage': 0, 'heat-temp-max': 86.0, 'heat-temp-min': 50.0, - 'heat-temp-set': 77.0, + 'heat-temp-set': 73.0, 'id': 1, 'master': True, 'mode': 7, diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 34844e34370..f33d1a8b28a 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -221,7 +221,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_MAX_TEMP) == 32.2 assert state.attributes.get(ATTR_MIN_TEMP) == 17.8 assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 22.8 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 22.8 HVAC_MOCK_CHANGED = copy.deepcopy(HVAC_MOCK) HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MAX_TEMP] = 25 @@ -594,8 +595,8 @@ async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: { API_SYSTEM_ID: 3, API_ZONE_ID: 1, - API_COOL_SET_POINT: 68.0, - API_HEAT_SET_POINT: 77.0, + API_COOL_SET_POINT: 77.0, + API_HEAT_SET_POINT: 68.0, } ] } @@ -618,5 +619,5 @@ async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.dkn_plus") - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 20.0 - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index a3454549e05..f83eceaae9c 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -245,10 +245,10 @@ HVAC_MOCK = { API_ZONE_ID: 1, API_NAME: "DKN Plus", API_ON: 1, - API_COOL_SET_POINT: 73, + API_COOL_SET_POINT: 77, API_COOL_MAX_TEMP: 90, API_COOL_MIN_TEMP: 64, - API_HEAT_SET_POINT: 77, + API_HEAT_SET_POINT: 73, API_HEAT_MAX_TEMP: 86, API_HEAT_MIN_TEMP: 50, API_MAX_TEMP: 90, From 5ce782d5970b0885377e6428b6d6d463cd804e3f Mon Sep 17 00:00:00 2001 From: Chris Cohen <40076937+cohenchris@users.noreply.github.com> Date: Sat, 16 Dec 2023 10:27:46 -0800 Subject: [PATCH 437/927] Bump glances-api to 0.5.0 (#105813) bump glances API to 0.5.0 --- homeassistant/components/glances/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index d90f7b8274c..d022995b786 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances-api==0.4.3"] + "requirements": ["glances-api==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b60f455b3ed..9896a815c7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -904,7 +904,7 @@ gios==3.2.2 gitterpy==0.1.7 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d92ab48c98b..374de3128d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -721,7 +721,7 @@ getmac==0.8.2 gios==3.2.2 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 From 4cca17458674edaa7f224755af46f797b1c6eb95 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 16 Dec 2023 19:38:58 +0100 Subject: [PATCH 438/927] Address late review comments on AVM FRITZ!SmartHome (#105860) set copies dict keys by default --- homeassistant/components/fritzbox/binary_sensor.py | 2 +- homeassistant/components/fritzbox/button.py | 2 +- homeassistant/components/fritzbox/climate.py | 2 +- homeassistant/components/fritzbox/cover.py | 2 +- homeassistant/components/fritzbox/light.py | 2 +- homeassistant/components/fritzbox/sensor.py | 2 +- homeassistant/components/fritzbox/switch.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index e766a53518a..e36056d2fab 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -85,7 +85,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index e56ebc1e3b0..6695c564331 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -29,7 +29,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.templates.keys())) + _add_entities(set(coordinator.data.templates)) class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 6ce885a3fdb..f648d4b3966 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -66,7 +66,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index d3d4c9080ea..4c2ba76c377 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -38,7 +38,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 88d32fe33a5..cb0c8594695 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -44,7 +44,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzboxLight(FritzBoxDeviceEntity, LightEntity): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index fda8b239859..140ecaef331 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -230,7 +230,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 4a2960a18ea..4d93cddb617 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -33,7 +33,7 @@ async def async_setup_entry( entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - _add_entities(set(coordinator.data.devices.keys())) + _add_entities(set(coordinator.data.devices)) class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): From cd15283c2e22b615c6cee0017436f3f9851efee8 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 16 Dec 2023 20:08:28 +0100 Subject: [PATCH 439/927] Bump bthome_ble to 3.3.1 (#105834) Co-authored-by: J. Nick Koston --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index a7729cc256e..be64f01966f 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.2.0"] + "requirements": ["bthome-ble==3.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9896a815c7e..50ca728a26d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -598,7 +598,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 374de3128d8..ba613e10513 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -503,7 +503,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.buienradar buienradar==1.0.5 From 299a2ef04eb4d2ff86f96376711b67b9f18d7796 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 16 Dec 2023 21:16:30 +0100 Subject: [PATCH 440/927] Bump qingping_ble to 0.9.0 (#105876) --- homeassistant/components/qingping/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 17593f8c404..5cde039c5ce 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/qingping", "iot_class": "local_push", - "requirements": ["qingping-ble==0.8.2"] + "requirements": ["qingping-ble==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50ca728a26d..6a13a6b71b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2324,7 +2324,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba613e10513..3d3b5bf0a8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1748,7 +1748,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 From 537dbd837513c0446d5ad46b10733cd5116d7bc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Dec 2023 14:23:56 -1000 Subject: [PATCH 441/927] Fix cloud tests doing socket I/O (#105874) https://github.com/home-assistant/core/actions/runs/7233101649/job/19708631179?pr=105868 https://github.com/home-assistant/core/actions/runs/7232949349?pr=105834 I was hoping to only patch the library, but when I did that it still failed because it had no access token --- tests/components/cloud/test_http_api.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index a04729faf67..3d7e6a69e3c 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1204,10 +1204,19 @@ async def test_list_alexa_entities( "interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"], } - # Add the entity to the entity registry - entity_registry.async_get_or_create( - "light", "test", "unique", suggested_object_id="kitchen" - ) + with patch( + ( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" + ".async_get_access_token" + ), + ), patch( + "homeassistant.components.cloud.alexa_config.alexa_state_report.async_send_add_or_update_message" + ): + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + await hass.async_block_till_done() with patch( "homeassistant.components.alexa.entities.async_get_entities", From 87c081e70d01aa8b788645c751513a1f50c52071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Dec 2023 16:07:21 -1000 Subject: [PATCH 442/927] Bump aiodiscover to 1.6.0 (#105885) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 3d9a5578045..db6e5948196 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.6.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8660ca963a1..a4b697d5151 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodiscover==1.5.1 +aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 aiohttp==3.9.1 diff --git a/requirements_all.txt b/requirements_all.txt index 6a13a6b71b0..a534e29e58e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -218,7 +218,7 @@ aiobotocore==2.6.0 aiocomelit==0.6.2 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d3b5bf0a8c..dff7f3e5a40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ aiobotocore==2.6.0 aiocomelit==0.6.2 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 From e78588a585f0b5c647e87038fb05ef998142471f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 01:48:08 -1000 Subject: [PATCH 443/927] Bump bluetooth-adapters to 0.16.2 (#105878) changelog: https://github.com/Bluetooth-Devices/bluetooth-adapters/compare/v0.16.1...v0.16.2 --- 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 c64784aac7c..b5bce32148a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.3.0", - "bluetooth-adapters==0.16.1", + "bluetooth-adapters==0.16.2", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.18.0", "dbus-fast==2.21.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a4b697d5151..2bb39cb935b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ awesomeversion==23.11.0 bcrypt==4.0.1 bleak-retry-connector==3.3.0 bleak==0.21.1 -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.18.0 certifi>=2021.5.30 diff --git a/requirements_all.txt b/requirements_all.txt index a534e29e58e..787bba5aa94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -560,7 +560,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dff7f3e5a40..5035e48b141 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ blinkpy==0.22.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 From 89513efd8dec2243d7c3c64bad946b52c5f8f011 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 04:42:28 -1000 Subject: [PATCH 444/927] Refactor ESPHome Bluetooth connection logic to prepare for esphome-bleak (#105747) --- .coveragerc | 1 - .../components/esphome/bluetooth/__init__.py | 50 +++++++++---------- .../components/esphome/entry_data.py | 20 ++++++++ homeassistant/components/esphome/manager.py | 20 +++----- .../components/esphome/bluetooth/__init__.py | 1 + .../esphome/bluetooth/test_client.py | 10 ++-- .../components/esphome/bluetooth/test_init.py | 46 +++++++++++++++++ tests/components/esphome/conftest.py | 49 ++++++++++++++++++ 8 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 tests/components/esphome/bluetooth/__init__.py create mode 100644 tests/components/esphome/bluetooth/test_init.py diff --git a/.coveragerc b/.coveragerc index d8079d3ee65..3d34939dbd9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -337,7 +337,6 @@ omit = homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py - homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 801b32ac2a3..88f47fe601d 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -5,9 +5,9 @@ import asyncio from collections.abc import Coroutine from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from aioesphomeapi import APIClient, BluetoothProxyFeature +from aioesphomeapi import APIClient, BluetoothProxyFeature, DeviceInfo from bleak_esphome.backend.cache import ESPHomeBluetoothCache from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice @@ -17,7 +17,6 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, async_register_scanner, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from ..entry_data import RuntimeEntryData @@ -25,20 +24,19 @@ from ..entry_data import RuntimeEntryData _LOGGER = logging.getLogger(__name__) -@hass_callback -def _async_can_connect( - entry_data: RuntimeEntryData, bluetooth_device: ESPHomeBluetoothDevice, source: str -) -> bool: +def _async_can_connect(bluetooth_device: ESPHomeBluetoothDevice, source: str) -> bool: """Check if a given source can make another connection.""" - can_connect = bool(entry_data.available and bluetooth_device.ble_connections_free) + can_connect = bool( + bluetooth_device.available and bluetooth_device.ble_connections_free + ) _LOGGER.debug( ( "%s [%s]: Checking can connect, available=%s, ble_connections_free=%s" " result=%s" ), - entry_data.name, + bluetooth_device.name, source, - entry_data.available, + bluetooth_device.available, bluetooth_device.ble_connections_free, can_connect, ) @@ -54,25 +52,25 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: async def async_connect_scanner( hass: HomeAssistant, - entry: ConfigEntry, - cli: APIClient, entry_data: RuntimeEntryData, + cli: APIClient, + device_info: DeviceInfo, cache: ESPHomeBluetoothCache, ) -> CALLBACK_TYPE: """Connect scanner.""" - assert entry.unique_id is not None - source = str(entry.unique_id) - device_info = entry_data.device_info - assert device_info is not None - feature_flags = device_info.bluetooth_proxy_feature_flags_compat( - entry_data.api_version - ) + source = device_info.mac_address + name = device_info.name + if TYPE_CHECKING: + assert cli.api_version is not None + feature_flags = device_info.bluetooth_proxy_feature_flags_compat(cli.api_version) connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) - bluetooth_device = ESPHomeBluetoothDevice(entry_data.name, device_info.mac_address) + bluetooth_device = ESPHomeBluetoothDevice( + name, device_info.mac_address, available=entry_data.available + ) entry_data.bluetooth_device = bluetooth_device _LOGGER.debug( "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", - entry.title, + name, source, feature_flags, connectable, @@ -82,8 +80,8 @@ async def async_connect_scanner( cache=cache, client=cli, device_info=device_info, - api_version=entry_data.api_version, - title=entry.title, + api_version=cli.api_version, + title=name, scanner=None, disconnect_callbacks=entry_data.disconnect_callbacks, ) @@ -92,11 +90,9 @@ async def async_connect_scanner( # https://github.com/python/mypy/issues/1484 client=partial(ESPHomeClient, client_data=client_data), # type: ignore[arg-type] source=source, - can_connect=hass_callback( - partial(_async_can_connect, entry_data, bluetooth_device, source) - ), + can_connect=partial(_async_can_connect, bluetooth_device, source), ) - scanner = ESPHomeScanner(source, entry.title, connector, connectable) + scanner = ESPHomeScanner(source, name, connector, connectable) client_data.scanner = scanner coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] # These calls all return a callback that can be used to unsubscribe diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index a824cf0256f..d9e5b199748 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -420,6 +420,8 @@ class RuntimeEntryData: Safe to call multiple times. """ self.available = False + if self.bluetooth_device: + self.bluetooth_device.available = False # Make a copy since calling the disconnect callbacks # may also try to discard/remove themselves. for disconnect_cb in self.disconnect_callbacks.copy(): @@ -428,3 +430,21 @@ class RuntimeEntryData: # to it and make sure all the callbacks can be GC'd. self.disconnect_callbacks.clear() self.disconnect_callbacks = set() + + @callback + def async_on_connect( + self, device_info: DeviceInfo, api_version: APIVersion + ) -> None: + """Call when the entry has been connected.""" + self.available = True + if self.bluetooth_device: + self.bluetooth_device.available = True + + self.device_info = device_info + self.api_version = api_version + # Reset expected disconnect flag on successful reconnect + # as it will be flipped to False on unexpected disconnect. + # + # We use this to determine if a deep sleep device should + # be marked as unavailable or not. + self.expected_disconnect = True diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 79e8a0a06fa..b4ae1a1d0ad 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -447,16 +447,10 @@ class ESPHomeManager: entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} ) - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - # Reset expected disconnect flag on successful reconnect - # as it will be flipped to False on unexpected disconnect. - # - # We use this to determine if a deep sleep device should - # be marked as unavailable or not. - entry_data.expected_disconnect = True + api_version = cli.api_version + assert api_version is not None, "API version must be set" + entry_data.async_on_connect(device_info, api_version) + if device_info.name: reconnect_logic.name = device_info.name @@ -472,10 +466,10 @@ class ESPHomeManager: setup_coros_with_disconnect_callbacks: list[ Coroutine[Any, Any, CALLBACK_TYPE] ] = [] - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + if device_info.bluetooth_proxy_feature_flags_compat(api_version): setup_coros_with_disconnect_callbacks.append( async_connect_scanner( - hass, entry, cli, entry_data, self.domain_data.bluetooth_cache + hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache ) ) @@ -507,7 +501,7 @@ class ESPHomeManager: entry_data.disconnect_callbacks.add(cancel_callback) hass.async_create_task(entry_data.async_save_to_store()) - _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_firmware_version(hass, device_info, api_version) _async_check_using_api_password(hass, device_info, bool(self.password)) async def on_disconnect(self, expected_disconnect: bool) -> None: diff --git a/tests/components/esphome/bluetooth/__init__.py b/tests/components/esphome/bluetooth/__init__.py new file mode 100644 index 00000000000..10ff361d85c --- /dev/null +++ b/tests/components/esphome/bluetooth/__init__.py @@ -0,0 +1 @@ +"""Bluetooth tests for ESPHome.""" diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 0c075aafa49..cd250bc1080 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -32,11 +32,11 @@ async def client_data_fixture( mac_address=ESP_MAC_ADDRESS, name=ESP_NAME, bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN - & BluetoothProxyFeature.ACTIVE_CONNECTIONS - & BluetoothProxyFeature.REMOTE_CACHING - & BluetoothProxyFeature.PAIRING - & BluetoothProxyFeature.CACHE_CLEARING - & BluetoothProxyFeature.RAW_ADVERTISEMENTS, + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS, ), api_version=APIVersion(1, 9), title=ESP_NAME, diff --git a/tests/components/esphome/bluetooth/test_init.py b/tests/components/esphome/bluetooth/test_init.py new file mode 100644 index 00000000000..d9d6f1947c9 --- /dev/null +++ b/tests/components/esphome/bluetooth/test_init.py @@ -0,0 +1,46 @@ +"""Test the ESPHome bluetooth integration.""" + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from ..conftest import MockESPHomeDevice + + +async def test_bluetooth_connect_with_raw_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with raw advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner is None + await mock_bluetooth_entry_with_raw_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner.scanning is True + + +async def test_bluetooth_connect_with_legacy_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with legacy advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner is None + await mock_bluetooth_entry_with_legacy_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + assert scanner.scanning is True diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 48b0868e406..d31eb70a0b4 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, Mock, patch from aioesphomeapi import ( APIClient, APIVersion, + BluetoothProxyFeature, DeviceInfo, EntityInfo, EntityState, @@ -311,6 +312,54 @@ async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfi return await mock_voice_assistant_entry(version=2) +@pytest.fixture +async def mock_bluetooth_entry( + hass: HomeAssistant, + mock_client: APIClient, +): + """Set up an ESPHome entry with bluetooth.""" + + async def _mock_bluetooth_entry( + bluetooth_proxy_feature_flags: BluetoothProxyFeature + ) -> MockESPHomeDevice: + return await _mock_generic_device_entry( + hass, + mock_client, + {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + ([], []), + [], + ) + + return _mock_bluetooth_entry + + +@pytest.fixture +async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth and raw advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS + ) + + +@pytest.fixture +async def mock_bluetooth_entry_with_legacy_adv( + mock_bluetooth_entry +) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth with legacy advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + ) + + @pytest.fixture async def mock_generic_device_entry( hass: HomeAssistant, From 7772f6042689542148da90b604ace6211e5eac8c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 17 Dec 2023 16:09:10 +0100 Subject: [PATCH 445/927] Migrate file test to use freezegun (#105892) --- tests/components/file/test_notify.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 0bcfcc38094..9cde648d27c 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -2,6 +2,7 @@ import os from unittest.mock import call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import notify @@ -28,7 +29,9 @@ async def test_bad_config(hass: HomeAssistant) -> None: True, ], ) -async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: +async def test_notify_file( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, timestamp: bool +) -> None: """Test the notify file output.""" filename = "mock_file" message = "one, two, testing, testing" @@ -47,10 +50,12 @@ async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: ) assert handle_config[notify.DOMAIN] + freezer.move_to(dt_util.utcnow()) + m_open = mock_open() with patch("homeassistant.components.file.notify.open", m_open, create=True), patch( "homeassistant.components.file.notify.os.stat" - ) as mock_st, patch("homeassistant.util.dt.utcnow", return_value=dt_util.utcnow()): + ) as mock_st: mock_st.return_value.st_size = 0 title = ( f"{ATTR_TITLE_DEFAULT} notifications " From 3f68abdd3a21c1f566dfb990d4a0ece13ac23a1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 06:00:38 -1000 Subject: [PATCH 446/927] Bump zeroconf to 0.130.0 (#105868) --- 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 c4d7cb923bc..8e5514696d2 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.129.0"] + "requirements": ["zeroconf==0.130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2bb39cb935b..a4b218b59f2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.129.0 +zeroconf==0.130.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 787bba5aa94..b4d731340e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2835,7 +2835,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.129.0 +zeroconf==0.130.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5035e48b141..1abb0e4d09d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2133,7 +2133,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.129.0 +zeroconf==0.130.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9490da830c1d1f8192848a17483901a7b1d83abc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 06:15:55 -1000 Subject: [PATCH 447/927] Bump bleak-esphome to 0.4.0 (#105909) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9ae6637876c..18c7aa407c1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "aioesphomeapi==21.0.0", "bluetooth-data-tools==1.18.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==0.3.0" + "bleak-esphome==0.4.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b4d731340e1..a44206cdbcf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -535,7 +535,7 @@ bimmer-connected[china]==0.14.6 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==0.3.0 +bleak-esphome==0.4.0 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1abb0e4d09d..cf4ffa9945f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ bellows==0.37.3 bimmer-connected[china]==0.14.6 # homeassistant.components.esphome -bleak-esphome==0.3.0 +bleak-esphome==0.4.0 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 67d903ca999c1a69a1822c5cd281ea1971bc7f33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 09:18:20 -1000 Subject: [PATCH 448/927] Remove bluetooth-data-tools dep from ESPHome (#105912) --- homeassistant/components/esphome/manifest.json | 1 - requirements_all.txt | 1 - requirements_test_all.txt | 1 - 3 files changed, 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 18c7aa407c1..770040746bb 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,6 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ "aioesphomeapi==21.0.0", - "bluetooth-data-tools==1.18.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==0.4.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index a44206cdbcf..17da7fdf4e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -566,7 +566,6 @@ bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf4ffa9945f..4addb11e3f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -478,7 +478,6 @@ bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device From 45b5ddfad7edd790d1d02e7de006fb692d0ba875 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 17 Dec 2023 22:08:18 +0000 Subject: [PATCH 449/927] Use library constants instead of literals in Evohome (#105039) * initial commit * roll back some consts * doctweak * tweak linting * doctweak --- homeassistant/components/evohome/__init__.py | 56 ++++++++++++------- homeassistant/components/evohome/climate.py | 36 ++++++++++-- .../components/evohome/water_heater.py | 13 ++++- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 9c33b0fbf31..fecfc2c0ef8 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -11,8 +11,24 @@ import re from typing import Any import evohomeasync +from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP import evohomeasync2 -import voluptuous as vol +from evohomeasync2.schema.const import ( + SZ_ALLOWED_SYSTEM_MODES, + SZ_AUTO_WITH_RESET, + SZ_CAN_BE_TEMPORARY, + SZ_HEAT_SETPOINT, + SZ_LOCATION_INFO, + SZ_SETPOINT_STATUS, + SZ_STATE_STATUS, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TIME_UNTIL, + SZ_TIME_ZONE, + SZ_TIMING_MODE, + SZ_UNTIL, +) +import voluptuous as vol # type: ignore[import-untyped] from homeassistant.const import ( ATTR_ENTITY_ID, @@ -243,17 +259,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if _LOGGER.isEnabledFor(logging.DEBUG): _config: dict[str, Any] = { - "locationInfo": {"timeZone": None}, + SZ_LOCATION_INFO: {SZ_TIME_ZONE: None}, GWS: [{TCS: None}], } - _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"] + _config[SZ_LOCATION_INFO][SZ_TIME_ZONE] = loc_config[SZ_LOCATION_INFO][ + SZ_TIME_ZONE + ] _config[GWS][0][TCS] = loc_config[GWS][0][TCS] _LOGGER.debug("Config = %s", _config) client_v1 = evohomeasync.EvohomeClient( client_v2.username, client_v2.password, - session_id=user_data.get("sessionId") if user_data else None, # STORAGE_VER 1 + session_id=user_data.get(SZ_SESSION_ID) if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -333,25 +351,25 @@ def setup_service_functions(hass: HomeAssistant, broker): hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.config["allowedSystemModes"] + modes = broker.config[SZ_ALLOWED_SYSTEM_MODES] # Not all systems support "AutoWithReset": register this handler only if required - if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: + if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: hass.services.async_register(DOMAIN, SVC_RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] # Permanent-only modes will use this schema - perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] + perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) - modes = [m for m in modes if m["canBeTemporary"]] + modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -365,7 +383,7 @@ def setup_service_functions(hass: HomeAssistant, broker): system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { @@ -509,7 +527,7 @@ class EvoBroker: ) self.client_v1 = None else: - self.temps = {str(i["id"]): i["temp"] for i in temps} + self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} finally: if self.client_v1 and session_id != self.client_v1.broker.session_id: @@ -591,12 +609,12 @@ class EvoDevice(Entity): def extra_state_attributes(self) -> dict[str, Any]: """Return the evohome-specific state attributes.""" status = self._device_state_attrs - if "systemModeStatus" in status: - convert_until(status["systemModeStatus"], "timeUntil") - if "setpointStatus" in status: - convert_until(status["setpointStatus"], "until") - if "stateStatus" in status: - convert_until(status["stateStatus"], "until") + if SZ_SYSTEM_MODE_STATUS in status: + convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) + if SZ_SETPOINT_STATUS in status: + convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) + if SZ_STATE_STATUS in status: + convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) return {"status": convert_dict(status)} @@ -675,7 +693,7 @@ class EvoChild(EvoDevice): self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() try: - self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] except KeyError: self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index dea5676d332..ec518ea4a99 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -5,6 +5,20 @@ from datetime import datetime as dt import logging from typing import Any +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_ALLOWED_SYSTEM_MODES, + SZ_SETPOINT_STATUS, + SZ_SYSTEM_ID, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TEMPERATURE_STATUS, + SZ_UNTIL, + SZ_ZONE_ID, + ZoneModelType, + ZoneType, +) + from homeassistant.components.climate import ( PRESET_AWAY, PRESET_ECO, @@ -71,8 +85,13 @@ EVO_PRESET_TO_HA = { } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] -STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] +STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] +STATE_ATTRS_ZONES = [ + SZ_ZONE_ID, + SZ_ACTIVE_FAULTS, + SZ_SETPOINT_STATUS, + SZ_TEMPERATURE_STATUS, +] async def async_setup_platform( @@ -98,7 +117,10 @@ async def async_setup_platform( entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] for zone in broker.tcs.zones.values(): - if zone.modelType == "HeatingZone" or zone.zoneType == "Thermostat": + if ( + zone.modelType == ZoneModelType.HEATING_ZONE + or zone.zoneType == ZoneType.THERMOSTAT + ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", zone.zoneType, @@ -237,7 +259,9 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) elif self._evo_device.mode == EVO_TEMPOVER: - until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) + until = dt_util.parse_datetime( + self._evo_device.setpointStatus[SZ_UNTIL] + ) until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( @@ -318,7 +342,7 @@ class EvoController(EvoClimateEntity): self._evo_id = evo_device.systemId self._attr_name = evo_device.location.name - modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] + modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.config[SZ_ALLOWED_SYSTEM_MODES]] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) ] @@ -400,7 +424,7 @@ class EvoController(EvoClimateEntity): attrs = self._device_state_attrs for attr in STATE_ATTRS_TCS: - if attr == "activeFaults": + if attr == SZ_ACTIVE_FAULTS: attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) else: attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 51617bdf1cf..b0e5c702787 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -3,6 +3,15 @@ from __future__ import annotations import logging +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_DHW_ID, + SZ_OFF, + SZ_ON, + SZ_STATE_STATUS, + SZ_TEMPERATURE_STATUS, +) + from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, @@ -26,10 +35,10 @@ _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] +STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] async def async_setup_platform( From 5c503683b761f62a62b92c28b63679a49e46888f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Dec 2023 13:16:31 -1000 Subject: [PATCH 450/927] Bump lru-dict to 1.3.0 (#105914) * Bump lru-dict to 1.3.0 changelog: https://github.com/amitdev/lru-dict/compare/v1.2.0...v1.3.0 * fix typing * quote types * quote types --- homeassistant/components/bluetooth/match.py | 10 +++------- homeassistant/components/http/static.py | 8 +++----- homeassistant/components/profiler/__init__.py | 2 +- .../components/recorder/table_managers/__init__.py | 10 +++++----- .../recorder/table_managers/event_types.py | 4 ++-- .../recorder/table_managers/statistics_meta.py | 4 ++-- homeassistant/helpers/template.py | 14 ++++++-------- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/profiler/test_init.py | 2 +- 11 files changed, 26 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 1315d0a834a..827006fe19d 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -7,7 +7,7 @@ from functools import lru_cache import re from typing import TYPE_CHECKING, Final, Generic, TypedDict, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import callback from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional @@ -15,8 +15,6 @@ from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional from .models import BluetoothCallback, BluetoothServiceInfoBleak if TYPE_CHECKING: - from collections.abc import MutableMapping - from bleak.backends.scanner import AdvertisementData @@ -97,10 +95,8 @@ class IntegrationMatcher: self._integration_matchers = integration_matchers # Some devices use a random address so we need to use # an LRU to avoid memory issues. - self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU( - MAX_REMEMBER_ADDRESSES - ) - self._matched_connectable: MutableMapping[str, IntegrationMatchHistory] = LRU( + self._matched: LRU[str, IntegrationMatchHistory] = LRU(MAX_REMEMBER_ADDRESSES) + self._matched_connectable: LRU[str, IntegrationMatchHistory] = LRU( MAX_REMEMBER_ADDRESSES ) self._index = BluetoothMatcherIndex() diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 1ab4ef5bd6f..7fe359d6486 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,7 @@ """Static file handling for HTTP component.""" from __future__ import annotations -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping import mimetypes from pathlib import Path from typing import Final @@ -10,7 +10,7 @@ from aiohttp import hdrs from aiohttp.web import FileResponse, Request, StreamResponse from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import HomeAssistant @@ -19,9 +19,7 @@ from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER} -PATH_CACHE: MutableMapping[ - tuple[str, Path, bool], tuple[Path | None, str | None] -] = LRU(512) +PATH_CACHE: LRU[tuple[str, Path, bool], tuple[Path | None, str | None]] = LRU(512) def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 8c5c206ae9f..5e4408bba20 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -11,7 +11,7 @@ import time import traceback from typing import Any, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import voluptuous as vol from homeassistant.components import persistent_notification diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index e56ee4f3415..9a0945dc4d9 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,9 +1,8 @@ """Managers for each table.""" -from collections.abc import MutableMapping from typing import TYPE_CHECKING, Generic, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU if TYPE_CHECKING: from ..core import Recorder @@ -14,6 +13,8 @@ _DataT = TypeVar("_DataT") class BaseTableManager(Generic[_DataT]): """Base class for table managers.""" + _id_map: "LRU[str, int]" + def __init__(self, recorder: "Recorder") -> None: """Initialize the table manager. @@ -24,7 +25,6 @@ class BaseTableManager(Generic[_DataT]): self.active = False self.recorder = recorder self._pending: dict[str, _DataT] = {} - self._id_map: MutableMapping[str, int] = {} def get_from_cache(self, data: str) -> int | None: """Resolve data to the id without accessing the underlying database. @@ -62,7 +62,7 @@ class BaseLRUTableManager(BaseTableManager[_DataT]): and evict the least recently used items when the cache is full. """ super().__init__(recorder) - self._id_map: MutableMapping[str, int] = LRU(lru_size) + self._id_map = LRU(lru_size) def adjust_lru_size(self, new_size: int) -> None: """Adjust the LRU cache size. @@ -70,6 +70,6 @@ class BaseLRUTableManager(BaseTableManager[_DataT]): This call is not thread-safe and must be called from the recorder thread. """ - lru: LRU = self._id_map + lru = self._id_map if new_size > lru.get_size(): lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 45b3b96353c..c74684a0f77 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy.orm.session import Session from homeassistant.core import Event @@ -28,7 +28,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) - self._non_existent_event_types: LRU = LRU(CACHE_SIZE) + self._non_existent_event_types: LRU[str, None] = LRU(CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index a484bdf145e..76def3a22fe 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -5,7 +5,7 @@ import logging import threading from typing import TYPE_CHECKING, Literal, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy import lambda_stmt, select from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true @@ -74,7 +74,7 @@ class StatisticsMetaManager: def __init__(self, recorder: Recorder) -> None: """Initialize the statistics meta manager.""" self.recorder = recorder - self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + self._stat_id_to_id_meta: LRU[str, tuple[int, StatisticMetaData]] = LRU( CACHE_SIZE ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index df8b1c1e019..9bb3759672f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping +from collections.abc import Callable, Collection, Generator, Iterable from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -40,7 +40,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import orjson import voluptuous as vol @@ -147,10 +147,8 @@ EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 -CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( - CACHED_TEMPLATE_STATES -) +CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) ENTITY_COUNT_GROWTH_FACTOR = 1.2 ORJSON_PASSTHROUGH_OPTIONS = ( @@ -187,9 +185,9 @@ def async_setup(hass: HomeAssistant) -> bool: ) for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): # There is no typing for LRU - current_size = lru.get_size() # type: ignore[attr-defined] + current_size = lru.get_size() if new_size > current_size: - lru.set_size(new_size) # type: ignore[attr-defined] + lru.set_size(new_size) from .event import ( # pylint: disable=import-outside-toplevel async_track_time_interval, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a4b218b59f2..9a1af1fe9f5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.9 packaging>=23.1 diff --git a/pyproject.toml b/pyproject.toml index 2e992da0ab3..304d2844cad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "home-assistant-bluetooth==1.11.0", "ifaddr==0.2.0", "Jinja2==3.1.2", - "lru-dict==1.2.0", + "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. "cryptography==41.0.7", diff --git a/requirements.txt b/requirements.txt index 4faf7f8b2c2..b9430b1bc91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ httpx==0.25.0 home-assistant-bluetooth==1.11.0 ifaddr==0.2.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 PyJWT==2.8.0 cryptography==41.0.7 pyOpenSSL==23.2.0 diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 7c2aeb2a29a..b8a81a40e37 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,7 +5,7 @@ import os from pathlib import Path from unittest.mock import patch -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import pytest from homeassistant.components.profiler import ( From aac02d7b844fddb30324a131c6fca3391853a7d1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Dec 2023 00:38:07 +0100 Subject: [PATCH 451/927] Add first TypeVarTuple annotations (#105379) --- homeassistant/helpers/ratelimit.py | 8 +++++--- homeassistant/util/async_.py | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 1da79eb5f7d..b2a93e7302f 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -5,11 +5,13 @@ import asyncio from collections.abc import Callable, Hashable from datetime import datetime, timedelta import logging -from typing import Any +from typing import TypeVarTuple from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -59,8 +61,8 @@ class KeyedRateLimit: key: Hashable, rate_limit: timedelta | None, now: datetime, - action: Callable, - *args: Any, + action: Callable[[*_Ts], None], + *args: *_Ts, ) -> datetime | None: """Check rate limits and schedule an action if we hit the limit. diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index bcc7be62265..1b8496fe327 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -10,7 +10,7 @@ import functools import logging import threading from traceback import extract_stack -from typing import Any, ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar, TypeVarTuple from homeassistant.exceptions import HomeAssistantError @@ -21,6 +21,7 @@ _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" _T = TypeVar("_T") _R = TypeVar("_R") _P = ParamSpec("_P") +_Ts = TypeVarTuple("_Ts") def cancelling(task: Future[Any]) -> bool: @@ -29,7 +30,7 @@ def cancelling(task: Future[Any]) -> bool: def run_callback_threadsafe( - loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any + loop: AbstractEventLoop, callback: Callable[[*_Ts], _T], *args: *_Ts ) -> concurrent.futures.Future[_T]: """Submit a callback object to a given event loop. From a88335272a81cc0a68af5f1b2dff541780a4ea18 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 18 Dec 2023 11:33:26 +1000 Subject: [PATCH 452/927] Implement Review Feedback for Tessie (#105937) --- .../components/tessie/binary_sensor.py | 7 +++++- .../components/tessie/config_flow.py | 5 ++++ homeassistant/components/tessie/const.py | 1 - .../components/tessie/coordinator.py | 19 ++++++--------- homeassistant/components/tessie/sensor.py | 7 +----- homeassistant/components/tessie/strings.json | 13 ++++------ tests/components/tessie/test_coordinator.py | 24 ++++--------------- 7 files changed, 28 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index ca78b19a42b..297a59cac6d 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TessieStatus from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity @@ -27,6 +27,11 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( + TessieBinarySensorEntityDescription( + key="state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on=lambda x: x == TessieStatus.ONLINE, + ), TessieBinarySensorEntityDescription( key="charge_state_battery_heater_on", device_class=BinarySensorDeviceClass.HEAT, diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index 3e3207b264b..97d9d44af70 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -18,6 +18,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "url": "[my.tessie.com/settings/api](https://my.tessie.com/settings/api)" +} class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -57,6 +60,7 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, errors=errors, ) @@ -98,5 +102,6 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", data_schema=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, errors=errors, ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index dad9ba2345f..3aa7dbb185d 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -18,4 +18,3 @@ class TessieStatus(StrEnum): ASLEEP = "asleep" ONLINE = "online" - OFFLINE = "offline" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 397d9cb4dfc..0fdfbcc5345 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -20,7 +20,7 @@ TESSIE_SYNC_INTERVAL = 10 _LOGGER = logging.getLogger(__name__) -class TessieDataUpdateCoordinator(DataUpdateCoordinator): +class TessieDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Tessie API.""" def __init__( @@ -35,16 +35,15 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name="Tessie", - update_method=self.async_update_data, update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) self.api_key = api_key self.vin = vin self.session = async_get_clientsession(hass) - self.data = self._flattern(data) + self.data = self._flatten(data) self.did_first_update = False - async def async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: vehicle = await get_state( @@ -54,10 +53,6 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): use_cache=self.did_first_update, ) except ClientResponseError as e: - if e.status == HTTPStatus.REQUEST_TIMEOUT: - # Vehicle is offline, only update state and dont throw error - self.data["state"] = TessieStatus.OFFLINE - return self.data if e.status == HTTPStatus.UNAUTHORIZED: # Auth Token is no longer valid raise ConfigEntryAuthFailed from e @@ -66,22 +61,22 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): self.did_first_update = True if vehicle["state"] == TessieStatus.ONLINE: # Vehicle is online, all data is fresh - return self._flattern(vehicle) + return self._flatten(vehicle) # Vehicle is asleep, only update state self.data["state"] = vehicle["state"] return self.data - def _flattern( + def _flatten( self, data: dict[str, Any], parent: str | None = None ) -> dict[str, Any]: - """Flattern the data structure.""" + """Flatten the data structure.""" result = {} for key, value in data.items(): if parent: key = f"{parent}_{key}" if isinstance(value, dict): - result.update(self._flattern(value, key)) + result.update(self._flatten(value, key)) else: result[key] = value return result diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 2836b7e3931..6f79d986998 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN, TessieStatus +from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity @@ -40,11 +40,6 @@ class TessieSensorEntityDescription(SensorEntityDescription): DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( - TessieSensorEntityDescription( - key="state", - options=[status.value for status in TessieStatus], - device_class=SensorDeviceClass.ENUM, - ), TessieSensorEntityDescription( key="charge_state_usable_battery_level", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 8785f2aadc3..43ddd7b4954 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -10,7 +10,7 @@ "data": { "access_token": "[%key:common::config_flow::data::access_token%]" }, - "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + "description": "Enter your access token from {url}." }, "reauth_confirm": { "data": { @@ -23,14 +23,6 @@ }, "entity": { "sensor": { - "state": { - "name": "Status", - "state": { - "online": "Online", - "asleep": "Asleep", - "offline": "Offline" - } - }, "charge_state_usable_battery_level": { "name": "Battery level" }, @@ -96,6 +88,9 @@ } }, "binary_sensor": { + "state": { + "name": "Status" + }, "charge_state_battery_heater_on": { "name": "Battery heater" }, diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 50a9f2f7733..6fc263e6908 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -2,15 +2,13 @@ from datetime import timedelta from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL -from homeassistant.components.tessie.sensor import TessieStatus -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .common import ( ERROR_AUTH, ERROR_CONNECTION, - ERROR_TIMEOUT, ERROR_UNKNOWN, TEST_VEHICLE_STATE_ASLEEP, TEST_VEHICLE_STATE_ONLINE, @@ -31,7 +29,7 @@ async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_state.assert_called_once() - assert hass.states.get("sensor.test_status").state == TessieStatus.ONLINE + assert hass.states.get("binary_sensor.test_status").state == STATE_ON async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: @@ -43,7 +41,7 @@ async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_state.assert_called_once() - assert hass.states.get("sensor.test_status").state == TessieStatus.ASLEEP + assert hass.states.get("binary_sensor.test_status").state == STATE_OFF async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: @@ -55,19 +53,7 @@ async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> N async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_state.assert_called_once() - assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE - - -async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: - """Tests that the coordinator handles timeout errors.""" - - mock_get_state.side_effect = ERROR_TIMEOUT - await setup_platform(hass) - - async_fire_time_changed(hass, utcnow() + WAIT) - await hass.async_block_till_done() - mock_get_state.assert_called_once() - assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: @@ -89,4 +75,4 @@ async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> No async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_state.assert_called_once() - assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE From 2c54f8bf8eae2f845e37824640af24e787db86ad Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 18 Dec 2023 02:34:19 +0100 Subject: [PATCH 453/927] Bump pyasuswrt to 0.1.21 (#105922) --- homeassistant/components/asuswrt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 9ed09cee67f..f4b2e3386e9 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.20"] + "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.21"] } diff --git a/requirements_all.txt b/requirements_all.txt index 17da7fdf4e4..0284d5aa059 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1628,7 +1628,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.asuswrt -pyasuswrt==0.1.20 +pyasuswrt==0.1.21 # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4addb11e3f9..afea8fdc914 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,7 +1244,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.asuswrt -pyasuswrt==0.1.20 +pyasuswrt==0.1.21 # homeassistant.components.atag pyatag==0.3.5.3 From 017dc6604656331022069bdab7471c371fecc0e2 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Sun, 17 Dec 2023 22:19:29 -0500 Subject: [PATCH 454/927] Bump asyncsleepiq to v1.4.0 (#105939) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 874ae90ec4a..d58c20b14b8 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.3.7"] + "requirements": ["asyncsleepiq==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0284d5aa059..2b3b06db1f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -478,7 +478,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.0 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afea8fdc914..607a9a37b06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -424,7 +424,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.36.2 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.0 # homeassistant.components.aurora auroranoaa==0.0.3 From 6acbbec839b40ddb32bb76be7e747130c727bbf7 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Mon, 18 Dec 2023 01:10:14 -0500 Subject: [PATCH 455/927] Address late review comments for A. O. Smith reauth (#105941) --- homeassistant/components/aosmith/config_flow.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py index 36a1c215d68..899b7382359 100644 --- a/homeassistant/components/aosmith/config_flow.py +++ b/homeassistant/components/aosmith/config_flow.py @@ -23,11 +23,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _reauth_email: str | None - - def __init__(self): - """Start the config flow.""" - self._reauth_email = None + _reauth_email: str | None = None async def _async_validate_credentials( self, email: str, password: str @@ -70,7 +66,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_EMAIL, default=self._reauth_email): str, + vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, } ), From d50b79ba842c60bbf1dfaf5992a43ad5d23eba98 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 08:42:53 +0100 Subject: [PATCH 456/927] Add Tailwind integration (#105926) Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/tailwind/__init__.py | 29 +++++ .../components/tailwind/config_flow.py | 79 ++++++++++++++ homeassistant/components/tailwind/const.py | 9 ++ .../components/tailwind/coordinator.py | 39 +++++++ homeassistant/components/tailwind/entity.py | 25 +++++ .../components/tailwind/manifest.json | 10 ++ homeassistant/components/tailwind/number.py | 86 +++++++++++++++ .../components/tailwind/strings.json | 34 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tailwind/__init__.py | 1 + tests/components/tailwind/conftest.py | 74 +++++++++++++ tests/components/tailwind/fixtures/iq3.json | 24 ++++ .../tailwind/snapshots/test_config_flow.ambr | 40 +++++++ .../tailwind/snapshots/test_number.ambr | 87 +++++++++++++++ tests/components/tailwind/test_config_flow.py | 103 ++++++++++++++++++ tests/components/tailwind/test_init.py | 46 ++++++++ tests/components/tailwind/test_number.py | 46 ++++++++ 23 files changed, 758 insertions(+) create mode 100644 homeassistant/components/tailwind/__init__.py create mode 100644 homeassistant/components/tailwind/config_flow.py create mode 100644 homeassistant/components/tailwind/const.py create mode 100644 homeassistant/components/tailwind/coordinator.py create mode 100644 homeassistant/components/tailwind/entity.py create mode 100644 homeassistant/components/tailwind/manifest.json create mode 100644 homeassistant/components/tailwind/number.py create mode 100644 homeassistant/components/tailwind/strings.json create mode 100644 tests/components/tailwind/__init__.py create mode 100644 tests/components/tailwind/conftest.py create mode 100644 tests/components/tailwind/fixtures/iq3.json create mode 100644 tests/components/tailwind/snapshots/test_config_flow.ambr create mode 100644 tests/components/tailwind/snapshots/test_number.ambr create mode 100644 tests/components/tailwind/test_config_flow.py create mode 100644 tests/components/tailwind/test_init.py create mode 100644 tests/components/tailwind/test_number.py diff --git a/.strict-typing b/.strict-typing index 4ee01b15d1a..a9e86b65854 100644 --- a/.strict-typing +++ b/.strict-typing @@ -334,6 +334,7 @@ homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* +homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* diff --git a/CODEOWNERS b/CODEOWNERS index 17b5909471d..4ad2a38fa04 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1289,6 +1289,8 @@ build.json @home-assistant/supervisor /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck /tests/components/tailscale/ @frenck +/homeassistant/components/tailwind/ @frenck +/tests/components/tailwind/ @frenck /homeassistant/components/tami4/ @Guy293 /tests/components/tami4/ @Guy293 /homeassistant/components/tankerkoenig/ @guillempages @mib1185 diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py new file mode 100644 index 00000000000..2691e9c36e8 --- /dev/null +++ b/homeassistant/components/tailwind/__init__.py @@ -0,0 +1,29 @@ +"""Integration for Tailwind devices.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + +PLATFORMS = [Platform.NUMBER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tailwind device from a config entry.""" + coordinator = TailwindDataUpdateCoordinator(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 Tailwind config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py new file mode 100644 index 00000000000..b9bf5ab1a44 --- /dev/null +++ b/homeassistant/components/tailwind/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow to configure the Tailwind integration.""" +from __future__ import annotations + +from typing import Any + +from gotailwind import ( + Tailwind, + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + + +class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Tailwind config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + tailwind = Tailwind( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + session=async_get_clientsession(self.hass), + ) + try: + status = await tailwind.status() + except TailwindUnsupportedFirmwareVersionError: + return self.async_abort(reason="unsupported_firmware") + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"Tailwind {status.product}", + data=user_input, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST) + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={ + "url": "https://web.gotailwind.com/client/integration/local-control-key", + }, + errors=errors, + ) diff --git a/homeassistant/components/tailwind/const.py b/homeassistant/components/tailwind/const.py new file mode 100644 index 00000000000..99e5bb0f1bf --- /dev/null +++ b/homeassistant/components/tailwind/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tailwind integration.""" +from __future__ import annotations + +import logging +from typing import Final + +DOMAIN: Final = "tailwind" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py new file mode 100644 index 00000000000..46b3d074045 --- /dev/null +++ b/homeassistant/components/tailwind/coordinator.py @@ -0,0 +1,39 @@ +"""Data update coordinator for Tailwind.""" +from datetime import timedelta + +from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): + """Class to manage fetching Tailwind data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.tailwind = Tailwind( + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{entry.data[CONF_HOST]}", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> TailwindDeviceStatus: + """Fetch data from the Tailwind device.""" + try: + return await self.tailwind.status() + except TailwindError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py new file mode 100644 index 00000000000..1077e2eb888 --- /dev/null +++ b/homeassistant/components/tailwind/entity.py @@ -0,0 +1,25 @@ +"""Base entity for the Tailwind integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + + +class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TailwindDataUpdateCoordinator) -> None: + """Initialize an Tailwind entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.device_id)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json new file mode 100644 index 00000000000..4f4f5236f52 --- /dev/null +++ b/homeassistant/components/tailwind/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tailwind", + "name": "Tailwind", + "codeowners": ["@frenck"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tailwind", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["gotailwind==0.2.1"] +} diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py new file mode 100644 index 00000000000..19d23457121 --- /dev/null +++ b/homeassistant/components/tailwind/number.py @@ -0,0 +1,86 @@ +"""Number entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind, TailwindDeviceStatus + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(kw_only=True) +class TailwindNumberEntityDescription(NumberEntityDescription): + """Class describing Tailwind number entities.""" + + value_fn: Callable[[TailwindDeviceStatus], int] + set_value_fn: Callable[[Tailwind, float], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindNumberEntityDescription( + key="brightness", + icon="mdi:led-on", + translation_key="brightness", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.led_brightness, + set_value_fn=lambda tailwind, brightness: tailwind.status_led( + brightness=int(brightness), + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind number based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindNumberEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindNumberEntity(TailwindEntity, NumberEntity): + """Representation of a Tailwind number entity.""" + + entity_description: TailwindNumberEntityDescription + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + description: TailwindNumberEntityDescription, + ) -> None: + """Initiate Tailwind number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" + + @property + def native_value(self) -> int | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + await self.entity_description.set_value_fn(self.coordinator.tailwind, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json new file mode 100644 index 00000000000..9446b0c304c --- /dev/null +++ b/homeassistant/components/tailwind/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "Local control key token" + }, + "data_description": { + "host": "The hostname or IP address of your Tailwind device. You can find the IP address by going into the Tailwind app and selecting your Tailwind device's cog icon. The IP address is shown in the **Device Info** section.", + "token": "To find local control key token, browse to the [Tailwind web portal]({url}), log in with your Tailwind account, and select the [**Local Control Key**]({url}) tab. The 6-digit number shown is your local control key 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%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." + } + }, + "entity": { + "number": { + "brightness": { + "name": "Status LED brightness" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5936ac01b68..260efa41886 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -485,6 +485,7 @@ FLOWS = { "system_bridge", "tado", "tailscale", + "tailwind", "tami4", "tankerkoenig", "tasmota", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index af822143d50..1c7348f629c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5685,6 +5685,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tailwind": { + "name": "Tailwind", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "tami4": { "name": "Tami4 Edge / Edge+", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index cf590b53918..6dc2542347d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3102,6 +3102,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tailwind.*] +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.tami4.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2b3b06db1f6..ec23c9d1b80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -933,6 +933,9 @@ googlemaps==2.5.1 # homeassistant.components.slide goslide-api==0.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.1 + # homeassistant.components.govee_ble govee-ble==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 607a9a37b06..bcffdfcc7ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -744,6 +744,9 @@ google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.1 + # homeassistant.components.govee_ble govee-ble==0.24.0 diff --git a/tests/components/tailwind/__init__.py b/tests/components/tailwind/__init__.py new file mode 100644 index 00000000000..48c1de3d421 --- /dev/null +++ b/tests/components/tailwind/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the Tailwind integration.""" diff --git a/tests/components/tailwind/conftest.py b/tests/components/tailwind/conftest.py new file mode 100644 index 00000000000..b39a3598a3e --- /dev/null +++ b/tests/components/tailwind/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for the Tailwind integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from gotailwind import TailwindDeviceStatus +import pytest + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def device_fixture() -> str: + """Return the device fixtures for a specific device.""" + return "iq3" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Tailwind iQ3", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.127", + CONF_TOKEN: "123456", + }, + unique_id="3c:e9:0e:6d:21:84", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tailwind.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_tailwind(device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked Tailwind client.""" + with patch( + "homeassistant.components.tailwind.coordinator.Tailwind", autospec=True + ) as tailwind_mock, patch( + "homeassistant.components.tailwind.config_flow.Tailwind", + new=tailwind_mock, + ): + tailwind = tailwind_mock.return_value + tailwind.status.return_value = TailwindDeviceStatus.from_json( + load_fixture(f"{device_fixture}.json", DOMAIN) + ) + yield tailwind + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> MockConfigEntry: + """Set up the Tailwind integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tailwind/fixtures/iq3.json b/tests/components/tailwind/fixtures/iq3.json new file mode 100644 index 00000000000..1c8b2d5e0d4 --- /dev/null +++ b/tests/components/tailwind/fixtures/iq3.json @@ -0,0 +1,24 @@ +{ + "result": "OK", + "product": "iQ3", + "dev_id": "_3c_e9_e_6d_21_84_", + "proto_ver": "0.1", + "door_num": 2, + "night_mode_en": 0, + "fw_ver": "10.10", + "led_brightness": 100, + "data": { + "door1": { + "index": 0, + "status": "open", + "lockup": 0, + "disabled": 0 + }, + "door2": { + "index": 1, + "status": "open", + "lockup": 0, + "disabled": 0 + } + } +} diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..fc0572c2e42 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Tailwind iQ3', + 'unique_id': None, + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr new file mode 100644 index 00000000000..1d1444461ff --- /dev/null +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tailwind iQ3 Status LED brightness', + 'icon': 'mdi:led-on', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-on', + 'original_name': 'Status LED brightness', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '_3c_e9_e_6d_21_84_-brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py new file mode 100644 index 00000000000..186e1464e14 --- /dev/null +++ b/tests/components/tailwind/test_config_flow.py @@ -0,0 +1,103 @@ +"""Configuration flow tests for the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import ( + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the full happy path user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show user form on a connection error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.2", + CONF_TOKEN: "123456", + }, + ) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + + +async def test_unsupported_firmware_version( + hass: HomeAssistant, mock_tailwind: MagicMock +) -> None: + """Test configuration flow aborts when the firmware version is not supported.""" + mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unsupported_firmware" diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py new file mode 100644 index 00000000000..c15646f4459 --- /dev/null +++ b/tests/components/tailwind/test_init.py @@ -0,0 +1,46 @@ +"""Integration tests for the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindConnectionError + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_tailwind.status.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind configuration entry not ready.""" + mock_tailwind.status.side_effect = TailwindConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tailwind.status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tailwind/test_number.py b/tests/components/tailwind/test_number.py new file mode 100644 index 00000000000..b67af3d0e62 --- /dev/null +++ b/tests/components/tailwind/test_number.py @@ -0,0 +1,46 @@ +"""Tests for number entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import number +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test number entities provided by the Tailwind integration.""" + assert (state := hass.states.get("number.tailwind_iq3_status_led_brightness")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + assert len(mock_tailwind.status_led.mock_calls) == 0 + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert len(mock_tailwind.status_led.mock_calls) == 1 + mock_tailwind.status_led.assert_called_with(brightness=42) From e59e1d7f8e07cf79bcdd2e2bc2a2831f5dc6ecc7 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 18 Dec 2023 09:37:59 +0100 Subject: [PATCH 457/927] Add prices service calls for easyEnergy (#105175) --- .../components/easyenergy/__init__.py | 4 + .../components/easyenergy/services.py | 142 + .../components/easyenergy/services.yaml | 46 + .../components/easyenergy/strings.json | 57 + .../easyenergy/snapshots/test_services.ambr | 3808 +++++++++++++++++ tests/components/easyenergy/test_services.py | 59 + 6 files changed, 4116 insertions(+) create mode 100644 homeassistant/components/easyenergy/services.py create mode 100644 homeassistant/components/easyenergy/services.yaml create mode 100644 tests/components/easyenergy/snapshots/test_services.ambr create mode 100644 tests/components/easyenergy/test_services.py diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index 498a355f0ab..c6be8ab5d6c 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import EasyEnergyDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] @@ -25,6 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + await async_setup_services(hass, coordinator) + return True diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py new file mode 100644 index 00000000000..80a04c5921c --- /dev/null +++ b/homeassistant/components/easyenergy/services.py @@ -0,0 +1,142 @@ +"""Services for easyEnergy integration.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from easyenergy import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EasyEnergyDataUpdateCoordinator + +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" +ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + vol.Required(ATTR_INCL_VAT, default=True): bool, + } +) + + +class PriceType(str, Enum): + """Type of price.""" + + ENERGY_USAGE = "energy_usage" + ENERGY_RETURN = "energy_return" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse: + """Serialize prices to service response.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices + ] + } + + +async def __get_prices( + call: ServiceCall, + *, + coordinator: EasyEnergyDataUpdateCoordinator, + price_type: PriceType, +) -> ServiceResponse: + """Get prices from easyEnergy.""" + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.easyenergy.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + return __serialize_prices(data.timestamp_prices) + data = await coordinator.easyenergy.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + if price_type == PriceType.ENERGY_USAGE: + return __serialize_prices(data.timestamp_usage_prices) + return __serialize_prices(data.timestamp_return_prices) + + +async def async_setup_services( + hass: HomeAssistant, + coordinator: EasyEnergyDataUpdateCoordinator, +) -> None: + """Set up services for easyEnergy integration.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, coordinator=coordinator, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_USAGE_SERVICE_NAME, + partial( + __get_prices, coordinator=coordinator, price_type=PriceType.ENERGY_USAGE + ), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_RETURN_SERVICE_NAME, + partial( + __get_prices, coordinator=coordinator, price_type=PriceType.ENERGY_RETURN + ), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/easyenergy/services.yaml b/homeassistant/components/easyenergy/services.yaml new file mode 100644 index 00000000000..01b78431afb --- /dev/null +++ b/homeassistant/components/easyenergy/services.yaml @@ -0,0 +1,46 @@ +get_gas_prices: + fields: + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_usage_prices: + fields: + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_return_prices: + fields: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 93fb264b01d..56d793818cb 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -9,6 +9,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + } + }, "entity": { "sensor": { "current_hour_price": { @@ -42,5 +47,57 @@ "name": "Hours priced equal or higher than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from easyEnergy.", + "fields": { + "incl_vat": { + "name": "VAT Included", + "description": "Include or exclude VAT in the prices, default is true." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_usage_prices": { + "name": "Get energy usage prices", + "description": "Request usage energy prices from easyEnergy.", + "fields": { + "incl_vat": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + }, + "get_energy_return_prices": { + "name": "Get energy return prices", + "description": "Request return energy prices from easyEnergy.", + "fields": { + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr new file mode 100644 index 00000000000..c878709a997 --- /dev/null +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -0,0 +1,3808 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py new file mode 100644 index 00000000000..24bee929489 --- /dev/null +++ b/tests/components/easyenergy/test_services.py @@ -0,0 +1,59 @@ +"""Tests for the services provided by the easyEnergy integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.easyenergy.const import DOMAIN +from homeassistant.components.easyenergy.services import ( + ENERGY_RETURN_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the easyEnergy Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_USAGE_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_RETURN_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", [GAS_SERVICE_NAME, ENERGY_USAGE_SERVICE_NAME, ENERGY_RETURN_SERVICE_NAME] +) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}, {}]) +@pytest.mark.parametrize( + "start", [{"start": "2023-01-01 00:00:00"}, {"start": "incorrect date"}, {}] +) +@pytest.mark.parametrize( + "end", [{"end": "2023-01-01 00:00:00"}, {"end": "incorrect date"}, {}] +) +async def test_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the easyEnergy Service.""" + + data = incl_vat | start | end + + try: + response = await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + assert response == snapshot + except ServiceValidationError as e: + assert e == snapshot From 0d049d73cf9c09afbee612675483514b820db93d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Dec 2023 10:34:39 +0100 Subject: [PATCH 458/927] Add AndroidTV remote to strict typing (#105571) Co-authored-by: Robert Resch --- .strict-typing | 1 + homeassistant/components/androidtv_remote/__init__.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index a9e86b65854..ce9d84204a5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -62,6 +62,7 @@ homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* homeassistant.components.android_ip_webcam.* +homeassistant.components.androidtv_remote.* homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 9471504808c..c78321589a9 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -14,7 +14,7 @@ from androidtvremote2 import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN @@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def on_hass_stop(event) -> None: + def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" api.disconnect() diff --git a/mypy.ini b/mypy.ini index 6dc2542347d..7325c7fd357 100644 --- a/mypy.ini +++ b/mypy.ini @@ -380,6 +380,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.androidtv_remote.*] +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.anova.*] check_untyped_defs = true disallow_incomplete_defs = true From f912b9c34a93ab610750ef0e3d2642d2c142dd29 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 18 Dec 2023 10:37:22 +0100 Subject: [PATCH 459/927] Bump reolink_aio to 0.8.4 (#105946) --- 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 7dc81e83b53..e687fc5d9b1 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.8.3"] + "requirements": ["reolink-aio==0.8.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec23c9d1b80..de7383b9648 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2359,7 +2359,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.3 +reolink-aio==0.8.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bcffdfcc7ac..ad23605a97e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1774,7 +1774,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.3 +reolink-aio==0.8.4 # homeassistant.components.rflink rflink==0.0.65 From 90fef6b9c9458bbd127abc8e29f595c1cf88d72d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 10:39:15 +0100 Subject: [PATCH 460/927] Add Tailwind zeroconf discovery (#105949) --- .../components/tailwind/config_flow.py | 122 +++++++++-- .../components/tailwind/manifest.json | 10 +- .../components/tailwind/strings.json | 10 + homeassistant/generated/zeroconf.py | 6 + .../tailwind/snapshots/test_config_flow.ambr | 47 ++++- tests/components/tailwind/test_config_flow.py | 196 +++++++++++++++++- 6 files changed, 370 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index b9bf5ab1a44..612264907ad 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -4,17 +4,21 @@ from __future__ import annotations from typing import Any from gotailwind import ( + MIN_REQUIRED_FIRMWARE_VERSION, Tailwind, TailwindAuthenticationError, TailwindConnectionError, TailwindUnsupportedFirmwareVersionError, + tailwind_device_id_to_mac_address, ) import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_TOKEN -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, @@ -23,12 +27,18 @@ from homeassistant.helpers.selector import ( from .const import DOMAIN, LOGGER +LOCAL_CONTROL_KEY_URL = ( + "https://web.gotailwind.com/client/integration/local-control-key" +) + class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Tailwind config flow.""" VERSION = 1 + host: str + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -36,15 +46,13 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - tailwind = Tailwind( - host=user_input[CONF_HOST], - token=user_input[CONF_TOKEN], - session=async_get_clientsession(self.hass), - ) try: - status = await tailwind.status() - except TailwindUnsupportedFirmwareVersionError: - return self.async_abort(reason="unsupported_firmware") + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise except TailwindAuthenticationError: errors[CONF_TOKEN] = "invalid_auth" except TailwindConnectionError: @@ -52,11 +60,6 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - else: - return self.async_create_entry( - title=f"Tailwind {status.product}", - data=user_input, - ) else: user_input = {} @@ -72,8 +75,93 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): ), } ), - description_placeholders={ - "url": "https://web.gotailwind.com/client/integration/local-control-key", - }, + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery of a Tailwind device.""" + if not (device_id := discovery_info.properties.get("device_id")): + return self.async_abort(reason="no_device_id") + + if ( + version := discovery_info.properties.get("SW ver") + ) and version < MIN_REQUIRED_FIRMWARE_VERSION: + return self.async_abort(reason="unsupported_firmware") + + await self.async_set_unique_id( + format_mac(tailwind_device_id_to_mac_address(device_id)) + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.host = discovery_info.host + self.context.update( + { + "title_placeholders": { + "name": f"Tailwind {discovery_info.properties.get('product')}" + }, + "configuration_url": LOCAL_CONTROL_KEY_URL, + } + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + errors = {} + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=self.host, + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: + """Create entry.""" + tailwind = Tailwind( + host=host, token=token, session=async_get_clientsession(self.hass) + ) + + try: + status = await tailwind.status() + except TailwindUnsupportedFirmwareVersionError: + return self.async_abort(reason="unsupported_firmware") + + await self.async_set_unique_id( + format_mac(status.mac_address), raise_on_progress=False + ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: host, + CONF_TOKEN: token, + } + ) + + return self.async_create_entry( + title=f"Tailwind {status.product}", + data={CONF_HOST: host, CONF_TOKEN: token}, + ) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 4f4f5236f52..b604a8b4886 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -6,5 +6,13 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.2.1"] + "requirements": ["gotailwind==0.2.1"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "properties": { + "vendor": "tailwind" + } + } + ] } diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 9446b0c304c..9a11b46a40e 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -11,6 +11,15 @@ "host": "The hostname or IP address of your Tailwind device. You can find the IP address by going into the Tailwind app and selecting your Tailwind device's cog icon. The IP address is shown in the **Device Info** section.", "token": "To find local control key token, browse to the [Tailwind web portal]({url}), log in with your Tailwind account, and select the [**Local Control Key**]({url}) tab. The 6-digit number shown is your local control key token." } + }, + "zeroconf_confirm": { + "description": "Set up your discovered Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } } }, "error": { @@ -21,6 +30,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_device_id": "The discovered Tailwind device did not provide a device ID.", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 55570078d80..78f21f90b5e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -494,6 +494,12 @@ ZEROCONF = { "vendor": "synology*", }, }, + { + "domain": "tailwind", + "properties": { + "vendor": "tailwind", + }, + }, ], "_hue._tcp.local.": [ { diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr index fc0572c2e42..5c01f35e09c 100644 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -3,6 +3,7 @@ FlowResultSnapshot({ 'context': dict({ 'source': 'user', + 'unique_id': '3c:e9:0e:6d:21:84', }), 'data': dict({ 'host': '127.0.0.1', @@ -30,7 +31,51 @@ 'pref_disable_polling': False, 'source': 'user', 'title': 'Tailwind iQ3', - 'unique_id': None, + 'unique_id': '3c:e9:0e:6d:21:84', + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- +# name: test_zeroconf_flow + FlowResultSnapshot({ + 'context': dict({ + 'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Tailwind iQ3', + }), + 'unique_id': '3c:e9:0e:6d:21:84', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Tailwind iQ3', + 'unique_id': '3c:e9:0e:6d:21:84', 'version': 1, }), 'title': 'Tailwind iQ3', diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index 186e1464e14..b9361e5172f 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -1,4 +1,5 @@ """Configuration flow tests for the Tailwind integration.""" +from ipaddress import ip_address from unittest.mock import MagicMock from gotailwind import ( @@ -9,12 +10,15 @@ from gotailwind import ( import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import zeroconf from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -85,7 +89,7 @@ async def test_user_flow_errors( assert result2.get("type") == FlowResultType.CREATE_ENTRY -async def test_unsupported_firmware_version( +async def test_user_flow_unsupported_firmware_version( hass: HomeAssistant, mock_tailwind: MagicMock ) -> None: """Test configuration flow aborts when the firmware version is not supported.""" @@ -101,3 +105,191 @@ async def test_unsupported_firmware_version( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "unsupported_firmware" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when the device is already configured. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the zeroconf happy flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == FlowResultType.FORM + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0].get("flow_id") == result["flow_id"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: "987654"} + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("properties", "expected_reason"), + [ + ({"SW ver": "10.10"}, "no_device_id"), + ({"device_id": "_3c_e9_e_6d_21_84_", "SW ver": "0.0"}, "unsupported_firmware"), + ], +) +async def test_zeroconf_flow_abort_incompatible_properties( + hass: HomeAssistant, properties: dict[str, str], expected_reason: str +) -> None: + """Test the zeroconf aborts when it advertises incompatible data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties=properties, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "zeroconf_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + assert result3.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow_not_discovered_again( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zeroconf doesn't re-discover an existing device. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" From 3e50ca6cda06a693002e0e7c69cbaa214145d053 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 11:08:26 +0100 Subject: [PATCH 461/927] Set volume_step in frontier_silicon media_player (#105953) --- .../components/frontier_silicon/media_player.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 223abe26e55..565ee79b108 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -152,6 +152,9 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._max_volume: self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 + if self._max_volume: + self._attr_volume_step = 1 / self._max_volume + if self._attr_state != MediaPlayerState.OFF: info_name = await afsapi.get_play_name() info_text = await afsapi.get_play_text() @@ -239,18 +242,6 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_mute(mute) # volume - async def async_volume_up(self) -> None: - """Send volume up command.""" - volume = await self.fs_device.get_volume() - volume = int(volume or 0) + 1 - await self.fs_device.set_volume(min(volume, self._max_volume)) - - async def async_volume_down(self) -> None: - """Send volume down command.""" - volume = await self.fs_device.get_volume() - volume = int(volume or 0) - 1 - await self.fs_device.set_volume(max(volume, 0)) - async def async_set_volume_level(self, volume: float) -> None: """Set volume command.""" if self._max_volume: # Can't do anything sensible if not set From 253182c650336b757936c512dd33f80d72584bb5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 18 Dec 2023 11:13:20 +0100 Subject: [PATCH 462/927] Reolink change ir to switch (#105916) * Change IR from light to switch * Remove old entity * Add test * Apply suggestions from code review Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/reolink/__init__.py | 9 ++++- homeassistant/components/reolink/light.py | 10 ----- homeassistant/components/reolink/strings.json | 6 +-- homeassistant/components/reolink/switch.py | 10 +++++ tests/components/reolink/test_init.py | 38 ++++++++++++++++++- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7f8448d277d..028b2c89311 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -151,6 +151,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b cleanup_disconnected_cams(hass, config_entry.entry_id, host) + # Can be remove in HA 2024.6.0 + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) + for entity in entities: + if entity.domain == "light" and entity.unique_id.endswith("ir_lights"): + entity_reg.async_remove(entity.entity_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 8df69b156ad..bc739343c46 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -50,16 +50,6 @@ LIGHT_ENTITIES = ( get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), ), - ReolinkLightEntityDescription( - key="ir_lights", - cmd_key="GetIrLights", - translation_key="ir_lights", - icon="mdi:led-off", - entity_category=EntityCategory.CONFIG, - supported=lambda api, ch: api.supported(ch, "ir_lights"), - is_on_fn=lambda api, ch: api.ir_enabled(ch), - turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), - ), ReolinkLightEntityDescription( key="status_led", cmd_key="GetPowerLed", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 04b46323e11..04dd0e787ac 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -238,9 +238,6 @@ "floodlight": { "name": "Floodlight" }, - "ir_lights": { - "name": "Infra red lights in night mode" - }, "status_led": { "name": "Status LED" } @@ -373,6 +370,9 @@ } }, "switch": { + "ir_lights": { + "name": "Infra red lights in night mode" + }, "record_audio": { "name": "Record audio" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 352ba7a1103..2ec3149dc8d 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -48,6 +48,16 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="ir_lights", + cmd_key="GetIrLights", + translation_key="ir_lights", + icon="mdi:led-off", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ir_lights"), + value=lambda api, ch: api.ir_enabled(ch), + method=lambda api, ch, value: api.set_ir_lights(ch, value), + ), ReolinkSwitchEntityDescription( key="record_audio", cmd_key="GetEnc", diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 6a9a8b957db..65490129486 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,7 +19,7 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -172,6 +172,42 @@ async def test_cleanup_disconnected_cams( assert sorted(device_models) == sorted(expected_models) +async def test_cleanup_deprecated_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test deprecated ir_lights light entity is cleaned.""" + reolink_connect.channels = [0] + ir_id = f"{TEST_MAC}_0_ir_lights" + + entity_registry.async_get_or_create( + domain=Platform.LIGHT, + platform=const.DOMAIN, + unique_id=ir_id, + config_entry=config_entry, + suggested_object_id=ir_id, + disabled_by=None, + ) + + assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) + assert ( + entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + is None + ) + + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) is None + ) + assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: From 60fe6ff7713ba79a3bb5dbc7afd96f454c052265 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 18 Dec 2023 11:30:44 +0100 Subject: [PATCH 463/927] Bump motionblinds to 0.6.19 (#105951) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index cc31ff42edf..f9115cd8146 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.18"] + "requirements": ["motionblinds==0.6.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index de7383b9648..e5b3dc100ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1276,7 +1276,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.18 +motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad23605a97e..e750c33f2f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1000,7 +1000,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.18 +motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 From 3c73c0f17f2ed755eab8ae7e9e5a74b875149f53 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 12:12:56 +0100 Subject: [PATCH 464/927] Add reauth support to Tailwind (#105959) --- .../components/tailwind/config_flow.py | 54 +++++++++++- .../components/tailwind/coordinator.py | 10 ++- .../components/tailwind/strings.json | 10 +++ tests/components/tailwind/test_config_flow.py | 87 ++++++++++++++++++- tests/components/tailwind/test_init.py | 31 ++++++- 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 612264907ad..ae63bb1a5e2 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tailwind integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from gotailwind import ( @@ -14,7 +15,7 @@ from gotailwind import ( import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -38,6 +39,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 host: str + reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -140,6 +142,46 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with a Tailwind device.""" + self.reauth_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: + """Handle re-authentication with a Tailwind device.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + return await self._async_step_create_entry( + host=self.reauth_entry.data[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: """Create entry.""" tailwind = Tailwind( @@ -151,6 +193,16 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): except TailwindUnsupportedFirmwareVersionError: return self.async_abort(reason="unsupported_firmware") + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={CONF_HOST: host, CONF_TOKEN: token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + await self.async_set_unique_id( format_mac(status.mac_address), raise_on_progress=False ) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 46b3d074045..d918b093605 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -1,11 +1,17 @@ """Data update coordinator for Tailwind.""" from datetime import timedelta -from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError +from gotailwind import ( + Tailwind, + TailwindAuthenticationError, + TailwindDeviceStatus, + TailwindError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN 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 @@ -35,5 +41,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]) """Fetch data from the Tailwind device.""" try: return await self.tailwind.status() + except TailwindAuthenticationError as err: + raise ConfigEntryAuthFailed from err except TailwindError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 9a11b46a40e..01a254ca0dc 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -1,6 +1,15 @@ { "config": { "step": { + "reauth_confirm": { + "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + }, "user": { "description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.", "data": { @@ -31,6 +40,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_device_id": "The discovered Tailwind device did not provide a device ID.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." } }, diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index b9361e5172f..c6afc6e7aec 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -293,3 +293,88 @@ async def test_zeroconf_flow_not_discovered_again( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_TOKEN] == "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "987654"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py index c15646f4459..fb61d155008 100644 --- a/tests/components/tailwind/test_init.py +++ b/tests/components/tailwind/test_init.py @@ -1,10 +1,10 @@ """Integration tests for the Tailwind integration.""" from unittest.mock import MagicMock -from gotailwind import TailwindConnectionError +from gotailwind import TailwindAuthenticationError, TailwindConnectionError from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -44,3 +44,30 @@ async def test_config_entry_not_ready( assert len(mock_tailwind.status.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailwind.status.side_effect = TailwindAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id From 82f3f175378eb6d7c3573e853eecb366e314829e Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 18 Dec 2023 03:16:32 -0800 Subject: [PATCH 465/927] Bump opower to 0.1.0 (#105957) --- 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 2dba85c9469..89b62912710 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.41"] + "requirements": ["opower==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5b3dc100ba..6aa342698ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,7 +1425,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.41 +opower==0.1.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e750c33f2f8..51eabc118cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1101,7 +1101,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.41 +opower==0.1.0 # homeassistant.components.oralb oralb-ble==0.17.6 From 8d0ce6ead6510c1df3154b9f3e75644aadfb3ba4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:23:30 +0100 Subject: [PATCH 466/927] Improve rfxtrx typing (#105966) --- homeassistant/components/rfxtrx/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 9c5ffa586cd..3ce45e5610e 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -6,7 +6,7 @@ import binascii from collections.abc import Callable, Mapping import copy import logging -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple, TypeVarTuple, cast import RFXtrx as rfxtrxmod import voluptuous as vol @@ -50,6 +50,8 @@ DEFAULT_OFF_DELAY = 2.0 SIGNAL_EVENT = f"{DOMAIN}_event" +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -559,6 +561,8 @@ class RfxtrxCommandEntity(RfxtrxEntity): """Initialzie a switch or light device.""" super().__init__(device, device_id, event=event) - async def _async_send(self, fun: Callable[..., None], *args: Any) -> None: - rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] + async def _async_send( + self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts + ) -> None: + rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) From 393da7b2e00d043884942fe36e57a657d02e575f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 18 Dec 2023 13:27:37 +0100 Subject: [PATCH 467/927] Fix easyenergy async_setup_services declaration (#105947) --- homeassistant/components/easyenergy/__init__.py | 2 +- homeassistant/components/easyenergy/services.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index c6be8ab5d6c..6c00ec5a6a3 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_services(hass, coordinator) + async_setup_services(hass, coordinator) return True diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 80a04c5921c..4aeef2f4d71 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -14,6 +14,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.util import dt as dt_util @@ -109,7 +110,8 @@ async def __get_prices( return __serialize_prices(data.timestamp_return_prices) -async def async_setup_services( +@callback +def async_setup_services( hass: HomeAssistant, coordinator: EasyEnergyDataUpdateCoordinator, ) -> None: From 77c72f240230e0dddd4bfc6938ac5fa02e9d4f2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 13:55:12 +0100 Subject: [PATCH 468/927] Avoid mutating entity descriptions in solaredge (#105974) --- homeassistant/components/solaredge_local/sensor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index d0efcd0ec9b..f8e0595f28f 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from contextlib import suppress -from copy import copy -from dataclasses import dataclass +import dataclasses from datetime import timedelta import logging import statistics @@ -51,7 +50,7 @@ INVERTER_MODES = ( ) -@dataclass +@dataclasses.dataclass class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): """Describes SolarEdge-local sensor entity.""" @@ -231,10 +230,11 @@ def setup_platform( data = SolarEdgeData(hass, api) # Changing inverter temperature unit. - inverter_temp_description = copy(SENSOR_TYPE_INVERTER_TEMPERATURE) + inverter_temp_description = SENSOR_TYPE_INVERTER_TEMPERATURE if status.inverters.primary.temperature.units.farenheit: - inverter_temp_description.native_unit_of_measurement = ( - UnitOfTemperature.FAHRENHEIT + inverter_temp_description = dataclasses.replace( + inverter_temp_description, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ) # Create entities From 94d22c936ec10580a78f64fd7aaff8ce24ac970c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 13:57:11 +0100 Subject: [PATCH 469/927] Avoid mutating entity descriptions in tomorrowio (#105975) --- homeassistant/components/tomorrowio/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 947bbf6fd2f..88b5af79604 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -92,8 +92,9 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): ) if self.value_map is not None: - self.device_class = SensorDeviceClass.ENUM - self.options = [item.name.lower() for item in self.value_map] + options = [item.name.lower() for item in self.value_map] + object.__setattr__(self, "device_class", SensorDeviceClass.ENUM) + object.__setattr__(self, "options", options) # From https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileID/14285 From 57a6effd704e8db1924fb3147638f07718fa8dd9 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 18 Dec 2023 13:57:34 +0100 Subject: [PATCH 470/927] Fix unreachable Netatmo sensor returning false values (#105954) * Fix unreachable sensor returning false values * Clean up unnecessary code --- .../components/netatmo/manifest.json | 2 +- homeassistant/components/netatmo/sensor.py | 22 +++++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../netatmo/fixtures/getstationsdata.json | 14 ++---------- .../netatmo/snapshots/test_diagnostics.ambr | 6 +++-- tests/components/netatmo/test_sensor.py | 16 ++++++++++++-- 7 files changed, 36 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 7d84641874a..3860c70bbea 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==7.6.0"] + "requirements": ["pyatmo==8.0.0"] } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 10114a75f63..2f99b866cf2 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -447,17 +447,16 @@ class NetatmoWeatherSensor(NetatmoBase, SensorEntity): } ) - @property - def available(self) -> bool: - """Return entity availability.""" - return self.state is not None - @callback def async_update_callback(self) -> None: """Update the entity's state.""" if ( - state := getattr(self._module, self.entity_description.netatmo_name) - ) is None: + not self._module.reachable + or (state := getattr(self._module, self.entity_description.netatmo_name)) + is None + ): + if self.available: + self._attr_available = False return if self.entity_description.netatmo_name in { @@ -475,6 +474,7 @@ class NetatmoWeatherSensor(NetatmoBase, SensorEntity): else: self._attr_native_value = state + self._attr_available = True self.async_write_ha_state() @@ -519,7 +519,6 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): if not self._module.reachable: if self.available: self._attr_available = False - self._attr_native_value = None return self._attr_available = True @@ -565,9 +564,15 @@ class NetatmoSensor(NetatmoBase, SensorEntity): @callback def async_update_callback(self) -> None: """Update the entity's state.""" + if not self._module.reachable: + if self.available: + self._attr_available = False + return + if (state := getattr(self._module, self.entity_description.key)) is None: return + self._attr_available = True self._attr_native_value = state self.async_write_ha_state() @@ -777,7 +782,6 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): self.entity_description.key, self._area_name, ) - self._attr_native_value = None self._attr_available = False return diff --git a/requirements_all.txt b/requirements_all.txt index 6aa342698ee..ed88077bd08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1637,7 +1637,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.6.0 +pyatmo==8.0.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51eabc118cc..fee1c76b9f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1253,7 +1253,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.6.0 +pyatmo==8.0.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/tests/components/netatmo/fixtures/getstationsdata.json b/tests/components/netatmo/fixtures/getstationsdata.json index 10c3ca85e06..b0da0820699 100644 --- a/tests/components/netatmo/fixtures/getstationsdata.json +++ b/tests/components/netatmo/fixtures/getstationsdata.json @@ -475,22 +475,12 @@ "last_setup": 1558709954, "data_type": ["Temperature", "Humidity"], "battery_percent": 27, - "reachable": true, + "reachable": false, "firmware": 50, "last_message": 1644582699, "last_seen": 1644582699, "rf_status": 68, - "battery_vp": 4678, - "dashboard_data": { - "time_utc": 1644582648, - "Temperature": 9.4, - "Humidity": 57, - "min_temp": 6.7, - "max_temp": 9.8, - "date_max_temp": 1644534223, - "date_min_temp": 1644569369, - "temp_trend": "up" - } + "battery_vp": 4678 }, { "_id": "12:34:56:80:c1:ea", diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index cd547481de9..f1c54901445 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -561,26 +561,28 @@ 'access_doorbell', 'access_presence', 'read_bubendorff', + 'read_bfi', 'read_camera', 'read_carbonmonoxidedetector', 'read_doorbell', 'read_homecoach', 'read_magellan', + 'read_mhs1', 'read_mx', 'read_presence', 'read_smarther', 'read_smokedetector', 'read_station', 'read_thermostat', - 'read_mhs1', 'write_bubendorff', + 'write_bfi', 'write_camera', 'write_magellan', + 'write_mhs1', 'write_mx', 'write_presence', 'write_smarther', 'write_thermostat', - 'write_mhs1', ]), 'type': 'Bearer', }), diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 00cec6f8aa0..ce35873c3e5 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -10,8 +10,8 @@ from homeassistant.helpers import entity_registry as er from .common import TEST_TIME, selected_platforms -async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: - """Test weather sensor setup.""" +async def test_indoor_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: + """Test indoor sensor setup.""" with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -25,6 +25,18 @@ async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) - assert hass.states.get(f"{prefix}pressure").state == "1014.5" +async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: + """Test weather sensor unreachable.""" + with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + prefix = "sensor.villa_outdoor_" + + assert hass.states.get(f"{prefix}temperature").state == "unavailable" + + async def test_public_weather_sensor( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: From 7a9e303e206ec1475e0a29a2799d159970ac4059 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 13:58:57 +0100 Subject: [PATCH 471/927] Avoid mutating entity descriptions in onewire (#105970) --- homeassistant/components/onewire/sensor.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 34ed66bd511..cee3e64d29f 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -import copy -from dataclasses import dataclass +import dataclasses import logging import os from types import MappingProxyType @@ -43,7 +42,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclasses.dataclass class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): """Class describing OneWire sensor entities.""" @@ -393,10 +392,12 @@ def get_entities( ).decode() ) if is_leaf: - description = copy.deepcopy(description) - description.device_class = SensorDeviceClass.HUMIDITY - description.native_unit_of_measurement = PERCENTAGE - description.translation_key = f"wetness_{s_id}" + description = dataclasses.replace( + description, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + translation_key=f"wetness_{s_id}", + ) _LOGGER.info(description.translation_key) override_key = None if description.override_key: From 8518d46d28b6f42e94c0fd17ffe5dedad9ee12e9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 14:03:41 +0100 Subject: [PATCH 472/927] Avoid mutating entity descriptions in radarr (#105972) --- homeassistant/components/radarr/sensor.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index ab4315b269a..f36516ac05b 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from datetime import UTC, datetime from typing import Any, Generic @@ -39,21 +38,23 @@ def get_modified_description( description: RadarrSensorEntityDescription[T], mount: RootFolder ) -> tuple[RadarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass class RadarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int | datetime] -@dataclass +@dataclasses.dataclass class RadarrSensorEntityDescription( SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] ): From bad9598baa934d009e9e293e6567c8689a6a2fa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 14:03:57 +0100 Subject: [PATCH 473/927] Avoid mutating entity descriptions in ovo_energy (#105971) --- homeassistant/components/ovo_energy/sensor.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index b32a17f0323..89a05f99a81 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from datetime import datetime, timedelta from typing import Final @@ -33,7 +33,7 @@ KEY_LAST_ELECTRICITY_COST: Final = "last_electricity_cost" KEY_LAST_GAS_COST: Final = "last_gas_cost" -@dataclass +@dataclasses.dataclass class OVOEnergySensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" @@ -130,8 +130,11 @@ async def async_setup_entry( and coordinator.data.electricity[-1] is not None and coordinator.data.electricity[-1].cost is not None ): - description.native_unit_of_measurement = ( - coordinator.data.electricity[-1].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=( + coordinator.data.electricity[-1].cost.currency_unit + ), ) entities.append(OVOEnergySensor(coordinator, description, client)) if coordinator.data.gas: @@ -141,9 +144,12 @@ async def async_setup_entry( and coordinator.data.gas[-1] is not None and coordinator.data.gas[-1].cost is not None ): - description.native_unit_of_measurement = coordinator.data.gas[ - -1 - ].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=coordinator.data.gas[ + -1 + ].cost.currency_unit, + ) entities.append(OVOEnergySensor(coordinator, description, client)) async_add_entities(entities, True) From a2a9a8e23193879a97a9a0adaf43337fa7d58372 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 18 Dec 2023 08:04:19 -0500 Subject: [PATCH 474/927] Fix typo in deprecated comment (#105969) --- homeassistant/components/blink/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 8598868e2dc..b2a23b0aa31 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -32,7 +32,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), - # Camera Armed sensor is depreciated covered by switch and will be removed in 2023.6. + # Camera Armed sensor is deprecated covered by switch and will be removed in 2023.6. BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, translation_key="camera_armed", From 79aa888ca0aa696571c11f52c5eb01ee184dac95 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 14:05:15 +0100 Subject: [PATCH 475/927] Add diagnostics platform to Tailwind (#105965) --- .../components/tailwind/diagnostics.py | 18 ++++++++++++ .../tailwind/snapshots/test_diagnostics.ambr | 28 +++++++++++++++++++ tests/components/tailwind/test_diagnostics.py | 22 +++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 homeassistant/components/tailwind/diagnostics.py create mode 100644 tests/components/tailwind/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tailwind/test_diagnostics.py diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py new file mode 100644 index 00000000000..50c9b2266e2 --- /dev/null +++ b/homeassistant/components/tailwind/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return coordinator.data.to_dict() diff --git a/tests/components/tailwind/snapshots/test_diagnostics.ambr b/tests/components/tailwind/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1ddfe08a4e3 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'door1': dict({ + 'disabled': 0, + 'door_id': 'door1', + 'index': 0, + 'lockup': 0, + 'status': 'open', + }), + 'door2': dict({ + 'disabled': 0, + 'door_id': 'door2', + 'index': 1, + 'lockup': 0, + 'status': 'open', + }), + }), + 'dev_id': '_3c_e9_e_6d_21_84_', + 'door_num': 2, + 'fw_ver': '10.10', + 'led_brightness': 100, + 'night_mode_en': 0, + 'product': 'iQ3', + 'proto_ver': '0.1', + }) +# --- diff --git a/tests/components/tailwind/test_diagnostics.py b/tests/components/tailwind/test_diagnostics.py new file mode 100644 index 00000000000..3151d323bce --- /dev/null +++ b/tests/components/tailwind/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for diagnostics provided by the Tailwind integration.""" + +from syrupy.assertion import SnapshotAssertion + +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 + + +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) + == snapshot + ) From 4f11419ae759014669c6e90b02ed150d8f382842 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 18 Dec 2023 05:07:08 -0800 Subject: [PATCH 476/927] Bump google-generativeai to 0.3.1 (#105783) --- .../components/google_generative_ai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 65d9e0b3894..5bafa9c43de 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.1.0"] + "requirements": ["google-generativeai==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed88077bd08..a9a5e9f8e7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -922,7 +922,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fee1c76b9f4..39768ea7cbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -736,7 +736,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 From 4bdfea5d67666a516adf2c2ceaedc7f5eb04c0f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Dec 2023 03:11:06 -1000 Subject: [PATCH 477/927] Remove HaAsyncServiceBrowser from zeroconf (#105881) --- homeassistant/components/zeroconf/__init__.py | 15 ++-- homeassistant/components/zeroconf/models.py | 27 +------ tests/components/zeroconf/test_init.py | 72 +++++++++---------- tests/conftest.py | 2 +- 4 files changed, 44 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 03662ef4ce6..5cf068e2d70 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -20,7 +20,7 @@ from zeroconf import ( IPVersion, ServiceStateChange, ) -from zeroconf.asyncio import AsyncServiceInfo +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network @@ -39,7 +39,7 @@ from homeassistant.loader import ( ) from homeassistant.setup import async_when_setup_or_start -from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf +from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) @@ -227,7 +227,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf_types, homekit_model_lookup, homekit_model_matchers, - ipv6, ) await discovery.async_setup() @@ -369,7 +368,6 @@ class ZeroconfDiscovery: zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ipv6: bool, ) -> None: """Init discovery.""" self.hass = hass @@ -377,10 +375,7 @@ class ZeroconfDiscovery: self.zeroconf_types = zeroconf_types self.homekit_model_lookups = homekit_model_lookups self.homekit_model_matchers = homekit_model_matchers - - self.ipv6 = ipv6 - - self.async_service_browser: HaAsyncServiceBrowser | None = None + self.async_service_browser: AsyncServiceBrowser | None = None async def async_setup(self) -> None: """Start discovery.""" @@ -392,8 +387,8 @@ class ZeroconfDiscovery: if hk_type not in self.zeroconf_types: types.append(hk_type) _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = HaAsyncServiceBrowser( - self.ipv6, self.zeroconf, types, handlers=[self.async_service_update] + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] ) async def async_stop(self) -> None: diff --git a/homeassistant/components/zeroconf/models.py b/homeassistant/components/zeroconf/models.py index ffa5e1a2ecf..7393e699b51 100644 --- a/homeassistant/components/zeroconf/models.py +++ b/homeassistant/components/zeroconf/models.py @@ -1,11 +1,7 @@ """Models for Zeroconf.""" -from typing import Any - -from zeroconf import DNSAddress, DNSRecord, Zeroconf -from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf - -TYPE_AAAA = 28 +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf class HaZeroconf(Zeroconf): @@ -24,22 +20,3 @@ class HaAsyncZeroconf(AsyncZeroconf): """Fake method to avoid integrations closing it.""" ha_async_close = AsyncZeroconf.async_close - - -class HaAsyncServiceBrowser(AsyncServiceBrowser): - """ServiceBrowser that only consumes DNSPointer records.""" - - def __init__(self, ipv6: bool, *args: Any, **kwargs: Any) -> None: - """Create service browser that filters ipv6 if it is disabled.""" - self.ipv6 = ipv6 - super().__init__(*args, **kwargs) - - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Pre-Filter AAAA records if IPv6 is not enabled.""" - if ( - not self.ipv6 - and isinstance(record, DNSAddress) - and record.type == TYPE_AAAA - ): - return - super().update_record(zc, now, record) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 54406bb1b4d..c94b2d66465 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -34,7 +34,7 @@ HOMEKIT_STATUS_UNPAIRED = b"1" HOMEKIT_STATUS_PAIRED = b"0" -def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=None): +def service_update_mock(zeroconf, services, handlers, *, limit_service=None): """Call service update handler.""" for service in services: if limit_service is not None and service != limit_service: @@ -165,7 +165,7 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -196,7 +196,7 @@ async def test_setup_with_overly_long_url_and_name( ) -> None: """Test we still setup with long urls and names.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -235,7 +235,7 @@ async def test_setup_with_defaults( ) -> None: """Test default interface config.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -254,7 +254,7 @@ async def test_zeroconf_match_macaddress( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -278,7 +278,7 @@ async def test_zeroconf_match_macaddress( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -298,7 +298,7 @@ async def test_zeroconf_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -318,7 +318,7 @@ async def test_zeroconf_match_manufacturer( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), @@ -337,7 +337,7 @@ async def test_zeroconf_match_model( ) -> None: """Test matching a specific model in zeroconf.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -357,7 +357,7 @@ async def test_zeroconf_match_model( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), @@ -376,7 +376,7 @@ async def test_zeroconf_match_manufacturer_not_present( ) -> None: """Test matchers reject when a property is missing.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -396,7 +396,7 @@ async def test_zeroconf_match_manufacturer_not_present( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), @@ -414,7 +414,7 @@ async def test_zeroconf_no_match( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -430,7 +430,7 @@ async def test_zeroconf_no_match( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -448,7 +448,7 @@ async def test_zeroconf_no_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -468,7 +468,7 @@ async def test_zeroconf_no_match_manufacturer( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), @@ -497,7 +497,7 @@ async def test_homekit_match_partial_space( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -535,7 +535,7 @@ async def test_device_with_invalid_name( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -568,7 +568,7 @@ async def test_homekit_match_partial_dash( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -601,7 +601,7 @@ async def test_homekit_match_partial_fnmatch( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -634,7 +634,7 @@ async def test_homekit_match_full( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -670,7 +670,7 @@ async def test_homekit_already_paired( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -704,7 +704,7 @@ async def test_homekit_invalid_paring_status( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -732,7 +732,7 @@ async def test_homekit_not_paired( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_homekit_info_mock( @@ -770,7 +770,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -810,7 +810,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -940,7 +940,7 @@ async def test_get_instance(hass: HomeAssistant, mock_async_zeroconf: None) -> N async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we remove it when a zeroconf entry is removed.""" - def service_update_mock(ipv6, zeroconf, services, handlers): + def service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -962,7 +962,7 @@ async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) - ) with patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -995,7 +995,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( hass.config_entries.flow, "async_init" ), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, @@ -1079,7 +1079,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1109,7 +1109,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1156,7 +1156,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1181,7 +1181,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1217,7 +1217,7 @@ async def test_setup_with_disallowed_characters_in_local_name( ) -> None: """Test we still setup with disallowed characters in the location name.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch.object( hass.config, "location_name", @@ -1248,7 +1248,7 @@ async def test_start_with_frontend( async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we dismiss flows when a PTR record is removed.""" - def _device_removed_mock(ipv6, zeroconf, services, handlers): + def _device_removed_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -1275,7 +1275,7 @@ async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" ) as mock_async_abort, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=_device_removed_mock + zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), diff --git a/tests/conftest.py b/tests/conftest.py index 1948b001ad4..696a5a2ed15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1131,7 +1131,7 @@ def mock_zeroconf() -> Generator[None, None, None]: with patch( "homeassistant.components.zeroconf.HaZeroconf", autospec=True ) as mock_zc, patch( - "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True + "homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True ): zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work From 556be26040591c83183b6ef18be0cd4f41592246 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 14:11:43 +0100 Subject: [PATCH 478/927] Add button platform to Tailwind integration (#105961) --- homeassistant/components/tailwind/__init__.py | 2 +- homeassistant/components/tailwind/button.py | 75 ++++++++++++++++++ .../tailwind/snapshots/test_button.ambr | 77 +++++++++++++++++++ tests/components/tailwind/test_button.py | 48 ++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tailwind/button.py create mode 100644 tests/components/tailwind/snapshots/test_button.ambr create mode 100644 tests/components/tailwind/test_button.py diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 2691e9c36e8..8f8ef4134a9 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator -PLATFORMS = [Platform.NUMBER] +PLATFORMS = [Platform.BUTTON, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py new file mode 100644 index 00000000000..d860f7de3d6 --- /dev/null +++ b/homeassistant/components/tailwind/button.py @@ -0,0 +1,75 @@ +"""Button entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(kw_only=True) +class TailwindButtonEntityDescription(ButtonEntityDescription): + """Class describing Tailwind button entities.""" + + press_fn: Callable[[Tailwind], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindButtonEntityDescription( + key="identify", + device_class=ButtonDeviceClass.IDENTIFY, + entity_category=EntityCategory.CONFIG, + press_fn=lambda tailwind: tailwind.identify(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind button based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindButtonEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindButtonEntity(TailwindEntity, ButtonEntity): + """Representation of a Tailwind button entity.""" + + entity_description: TailwindButtonEntityDescription + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + description: TailwindButtonEntityDescription, + ) -> None: + """Initiate Tailwind button entity.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" + + async def async_press(self) -> None: + """Trigger button press on the Tailwind device.""" + await self.entity_description.press_fn(self.coordinator.tailwind) diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr new file mode 100644 index 00000000000..b92b482e23d --- /dev/null +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Tailwind iQ3 Identify', + }), + 'context': , + 'entity_id': 'button.tailwind_iq3_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.tailwind_iq3_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_button.py b/tests/components/tailwind/test_button.py new file mode 100644 index 00000000000..708816d733c --- /dev/null +++ b/tests/components/tailwind/test_button.py @@ -0,0 +1,48 @@ +"""Tests for button entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2023-12-17 15:25:00"), +] + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test button entities provided by the Tailwind integration.""" + assert (state := hass.states.get("button.tailwind_iq3_identify")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + assert len(mock_tailwind.identify.mock_calls) == 0 + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert len(mock_tailwind.identify.mock_calls) == 1 + mock_tailwind.identify.assert_called_with() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2023-12-17T15:25:00+00:00" From 0972dc5867e5e598a06165733e7eb29450be2c79 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 18 Dec 2023 14:40:13 +0100 Subject: [PATCH 479/927] Migrate directv test to use freezegun (#105894) --- tests/components/directv/test_media_player.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 5dc76a2170e..48b12132cbe 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.directv.media_player import ( @@ -305,6 +306,7 @@ async def test_check_attributes( async def test_attributes_paused( hass: HomeAssistant, mock_now: dt_util.dt.datetime, + freezer: FrozenDateTimeFactory, aioclient_mock: AiohttpClientMocker, ) -> None: """Test attributes while paused.""" @@ -315,11 +317,9 @@ async def test_attributes_paused( # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not # updated if TV is paused. - with patch( - "homeassistant.util.dt.utcnow", return_value=mock_now + timedelta(minutes=5) - ): - await async_media_pause(hass, CLIENT_ENTITY_ID) - await hass.async_block_till_done() + freezer.move_to(mock_now + timedelta(minutes=5)) + await async_media_pause(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() state = hass.states.get(CLIENT_ENTITY_ID) assert state.state == STATE_PAUSED From 58070e14a7403fa53c379e487327ff69ffb2e7af Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 18 Dec 2023 14:40:55 +0100 Subject: [PATCH 480/927] Add significant Change support for camera (#105866) --- .../components/camera/significant_change.py | 22 +++++++++++++++++++ .../camera/test_significant_change.py | 19 ++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 homeassistant/components/camera/significant_change.py create mode 100644 tests/components/camera/test_significant_change.py diff --git a/homeassistant/components/camera/significant_change.py b/homeassistant/components/camera/significant_change.py new file mode 100644 index 00000000000..4fc175b0723 --- /dev/null +++ b/homeassistant/components/camera/significant_change.py @@ -0,0 +1,22 @@ +"""Helper to test significant Camera state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + return None diff --git a/tests/components/camera/test_significant_change.py b/tests/components/camera/test_significant_change.py new file mode 100644 index 00000000000..b1e1eb66589 --- /dev/null +++ b/tests/components/camera/test_significant_change.py @@ -0,0 +1,19 @@ +"""Test the Camera significant change platform.""" +from homeassistant.components.camera import STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Camera significant changes.""" + attrs = {} + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, attrs + ) + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, {"dummy": "dummy"} + ) + assert async_check_significant_change( + None, STATE_IDLE, attrs, STATE_RECORDING, attrs + ) From b671de8942460e0c16aafbe0a572f54728a9a4dc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Dec 2023 14:54:12 +0100 Subject: [PATCH 481/927] Improve logging util typing (#105968) --- homeassistant/util/logging.py | 41 ++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 07ff413a016..300b9ced616 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -9,11 +9,12 @@ import logging import logging.handlers import queue import traceback -from typing import Any, TypeVar, cast, overload +from typing import Any, TypeVar, TypeVarTuple, cast, overload from homeassistant.core import HomeAssistant, callback, is_callback _T = TypeVar("_T") +_Ts = TypeVarTuple("_Ts") class HomeAssistantQueueHandler(logging.handlers.QueueHandler): @@ -83,7 +84,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: listener.start() -def log_exception(format_err: Callable[..., Any], *args: Any) -> None: +def log_exception(format_err: Callable[[*_Ts], Any], *args: *_Ts) -> None: """Log an exception with additional context.""" module = inspect.getmodule(inspect.stack(context=0)[1].frame) if module is not None: @@ -102,9 +103,9 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: async def _async_wrapper( - async_func: Callable[..., Coroutine[Any, Any, None]], - format_err: Callable[..., Any], - *args: Any, + async_func: Callable[[*_Ts], Coroutine[Any, Any, None]], + format_err: Callable[[*_Ts], Any], + *args: *_Ts, ) -> None: """Catch and log exception.""" try: @@ -114,7 +115,7 @@ async def _async_wrapper( def _sync_wrapper( - func: Callable[..., Any], format_err: Callable[..., Any], *args: Any + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts ) -> None: """Catch and log exception.""" try: @@ -125,7 +126,7 @@ def _sync_wrapper( @callback def _callback_wrapper( - func: Callable[..., Any], format_err: Callable[..., Any], *args: Any + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts ) -> None: """Catch and log exception.""" try: @@ -136,21 +137,21 @@ def _callback_wrapper( @overload def catch_log_exception( - func: Callable[..., Coroutine[Any, Any, Any]], format_err: Callable[..., Any] -) -> Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Coroutine[Any, Any, Any]], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: ... @overload def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: ... def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: """Decorate a function func to catch and log exceptions. If func is a coroutine function, a coroutine function will be returned. @@ -159,24 +160,24 @@ def catch_log_exception( # Check for partials to properly determine if coroutine function check_func = func while isinstance(check_func, partial): - check_func = check_func.func + check_func = check_func.func # type: ignore[unreachable] # false positive if asyncio.iscoroutinefunction(check_func): - async_func = cast(Callable[..., Coroutine[Any, Any, None]], func) - return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) + async_func = cast(Callable[[*_Ts], Coroutine[Any, Any, None]], func) + return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) # type: ignore[return-value] if is_callback(check_func): - return wraps(func)(partial(_callback_wrapper, func, format_err)) + return wraps(func)(partial(_callback_wrapper, func, format_err)) # type: ignore[return-value] - return wraps(func)(partial(_sync_wrapper, func, format_err)) + return wraps(func)(partial(_sync_wrapper, func, format_err)) # type: ignore[return-value] def catch_log_coro_exception( - target: Coroutine[Any, Any, _T], format_err: Callable[..., Any], *args: Any + target: Coroutine[Any, Any, _T], format_err: Callable[[*_Ts], Any], *args: *_Ts ) -> Coroutine[Any, Any, _T | None]: """Decorate a coroutine to catch and log exceptions.""" - async def coro_wrapper(*args: Any) -> _T | None: + async def coro_wrapper(*args: *_Ts) -> _T | None: """Catch and log exception.""" try: return await target From 2515e520c1c0fe6e7966193f1a369d7aac224b8c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 15:30:59 +0100 Subject: [PATCH 482/927] Allow step size any for number selector in slider mode (#105978) --- homeassistant/helpers/selector.py | 3 --- tests/helpers/test_selector.py | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index f7ceb4ab812..ab22877f906 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -951,9 +951,6 @@ def validate_slider(data: Any) -> Any: if "min" not in data or "max" not in data: raise vol.Invalid("min and max are required in slider mode") - if "step" in data and data["step"] == "any": - raise vol.Invalid("step 'any' is not allowed in slider mode") - return data diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index c4ad244620b..e925b425f96 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -396,6 +396,7 @@ def test_assist_pipeline_selector_schema( ({"min": 10, "max": 1000, "mode": "slider", "step": 0.5}, (), ()), ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), + ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), ), ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -408,12 +409,6 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> ( {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode - { - "mode": "slider", - "min": 0, - "max": 1, - "step": "any", # Can't combine slider with step any - }, ), ) def test_number_selector_schema_error(schema) -> None: From 72fe30439e414efb2e052e72b08efd9f5718eb8a Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Mon, 18 Dec 2023 15:47:44 +0100 Subject: [PATCH 483/927] Rename zamg to GeoSphere Austria (#105494) --- homeassistant/components/zamg/const.py | 6 ++---- homeassistant/components/zamg/manifest.json | 2 +- homeassistant/components/zamg/sensor.py | 2 +- homeassistant/components/zamg/strings.json | 4 ++-- homeassistant/components/zamg/weather.py | 4 ++-- homeassistant/generated/integrations.json | 2 +- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zamg/const.py b/homeassistant/components/zamg/const.py index e1733600f59..ea1e91d6149 100644 --- a/homeassistant/components/zamg/const.py +++ b/homeassistant/components/zamg/const.py @@ -14,13 +14,11 @@ LOGGER = logging.getLogger(__package__) ATTR_STATION = "station" ATTR_UPDATED = "updated" -ATTRIBUTION = "Data provided by ZAMG" +ATTRIBUTION = "Data provided by GeoSphere Austria" CONF_STATION_ID = "station_id" -DEFAULT_NAME = "zamg" - -MANUFACTURER_URL = "https://www.zamg.ac.at" +MANUFACTURER_URL = "https://www.geosphere.at" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index f83e38002b8..e7fe584c767 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -1,6 +1,6 @@ { "domain": "zamg", - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "codeowners": ["@killer0071234"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 31275dd908d..344ba560f6a 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -202,7 +202,7 @@ class ZamgSensor(CoordinatorEntity, SensorEntity): identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) coordinator.api_fields = API_FIELDS diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json index a92e7aa605e..6ffc489bdf5 100644 --- a/homeassistant/components/zamg/strings.json +++ b/homeassistant/components/zamg/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Set up ZAMG to integrate with Home Assistant.", + "description": "Set up GeoSphere Austria to integrate with Home Assistant.", "data": { "station_id": "Station ID (Defaults to nearest station)" } @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "station_not_found": "Station ID not found at zamg" + "station_not_found": "Station ID not found at GeoSphere Austria" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 98e08106dca..e855bde29d8 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -43,14 +43,14 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) self._attr_unique_id = station_id - self._attr_name = f"ZAMG {name}" + self._attr_name = name self.station_id = f"{station_id}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) @property diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1c7348f629c..49c97002fc2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6717,7 +6717,7 @@ "iot_class": "local_polling" }, "zamg": { - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" From 5f2a13fec6ae026ae8b786a7723f8296d9d48fea Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Dec 2023 15:52:48 +0100 Subject: [PATCH 484/927] Add DHCP discovery to Tailwind (#105981) --- .../components/tailwind/config_flow.py | 14 ++++++ .../components/tailwind/manifest.json | 5 ++ .../components/tailwind/strings.json | 1 + homeassistant/generated/dhcp.py | 4 ++ tests/components/tailwind/test_config_flow.py | 50 ++++++++++++++++++- 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index ae63bb1a5e2..97515f17f3f 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,6 +15,7 @@ from gotailwind import ( import voluptuous as vol from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow, FlowResult @@ -182,6 +183,19 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery to update existing entries. + + This flow is triggered only by DHCP discovery of known devices. + """ + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + # This situation should never happen, as Home Assistant will only + # send updates for existing entries. In case it does, we'll just + # abort the flow with an unknown error. + return self.async_abort(reason="unknown") + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: """Create entry.""" tailwind = Tailwind( diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index b604a8b4886..5121c8408f0 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -3,6 +3,11 @@ "name": "Tailwind", "codeowners": ["@frenck"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 01a254ca0dc..bc765efa8d1 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -41,6 +41,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_device_id": "The discovered Tailwind device did not provide a device ID.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." } }, diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6d04d7602f2..33d069c5663 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -571,6 +571,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "tado", "hostname": "tado*", }, + { + "domain": "tailwind", + "registered_devices": True, + }, { "domain": "tesla_wall_connector", "hostname": "teslawallconnector_*", diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index c6afc6e7aec..6d35ccea85a 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -11,8 +11,14 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -378,3 +384,45 @@ async def test_reauth_flow_errors( assert result3.get("type") == FlowResultType.ABORT assert result3.get("reason") == "reauth_successful" + + +async def test_dhcp_discovery_updates_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates config entries.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: + """Test DHCP discovery is only used for updates. + + Anything else will just abort the flow. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unknown" From ab40ba80a936cdb92e596131f1b40d11e9726f83 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 18 Dec 2023 21:09:01 +0300 Subject: [PATCH 485/927] Improve Transmission Entity description class (#105924) * Add entity mixin for transmission sensors * use kw_only in EntityDescription class * minor fix * Update sensor.py uom --- .../components/transmission/sensor.py | 238 +++++++++--------- 1 file changed, 126 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 20f4fc95c87..29d37f28bad 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -1,7 +1,9 @@ """Support for monitoring the Transmission BitTorrent client API.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass from typing import Any from transmission_rpc.torrent import Torrent @@ -16,6 +18,7 @@ from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,23 +31,103 @@ from .const import ( ) from .coordinator import TransmissionDataUpdateCoordinator -SPEED_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="download", translation_key="download_speed"), - SensorEntityDescription(key="upload", translation_key="upload_speed"), -) +MODES: dict[str, list[str] | None] = { + "started_torrents": ["downloading"], + "completed_torrents": ["seeding"], + "paused_torrents": ["stopped"], + "active_torrents": [ + "seeding", + "downloading", + ], + "total_torrents": None, +} -STATUS_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="status", translation_key="transmission_status"), -) -TORRENT_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="active_torrents", translation_key="active_torrents"), - SensorEntityDescription(key="paused_torrents", translation_key="paused_torrents"), - SensorEntityDescription(key="total_torrents", translation_key="total_torrents"), - SensorEntityDescription( - key="completed_torrents", translation_key="completed_torrents" +@dataclass(kw_only=True) +class TransmissionSensorEntityDescription(SensorEntityDescription): + """Entity description class for Transmission sensors.""" + + val_func: Callable[[TransmissionDataUpdateCoordinator], StateType] + extra_state_attr_func: Callable[[Any], dict[str, str]] | None = None + + +SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( + TransmissionSensorEntityDescription( + key="download", + translation_key="download_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.download_speed), + ), + TransmissionSensorEntityDescription( + key="upload", + translation_key="upload_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.upload_speed), + ), + TransmissionSensorEntityDescription( + key="status", + translation_key="transmission_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + val_func=lambda coordinator: get_state( + coordinator.data.upload_speed, coordinator.data.download_speed + ), + ), + TransmissionSensorEntityDescription( + key="active_torrents", + translation_key="active_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.active_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="active_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="paused_torrents", + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.paused_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="paused_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="total_torrents", + translation_key="total_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="total_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="completed_torrents", + translation_key="completed_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="completed_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="started_torrents", + translation_key="started_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["started_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="started_torrents" + ), ), - SensorEntityDescription(key="started_torrents", translation_key="started_torrents"), ) @@ -59,22 +142,9 @@ async def async_setup_entry( config_entry.entry_id ] - entities: list[TransmissionSensor] = [] - - entities = [ - TransmissionSpeedSensor(coordinator, description) - for description in SPEED_SENSORS - ] - entities += [ - TransmissionStatusSensor(coordinator, description) - for description in STATUS_SENSORS - ] - entities += [ - TransmissionTorrentsSensor(coordinator, description) - for description in TORRENT_SENSORS - ] - - async_add_entities(entities) + async_add_entities( + TransmissionSensor(coordinator, description) for description in SENSOR_TYPES + ) class TransmissionSensor( @@ -82,12 +152,13 @@ class TransmissionSensor( ): """A base class for all Transmission sensors.""" + entity_description: TransmissionSensorEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - entity_description: SensorEntityDescription, + entity_description: TransmissionSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -101,85 +172,28 @@ class TransmissionSensor( manufacturer="Transmission", ) - -class TransmissionSpeedSensor(TransmissionSensor): - """Representation of a Transmission speed sensor.""" - - _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND - _attr_suggested_display_precision = 2 - _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + @property + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.val_func(self.coordinator) @property - def native_value(self) -> float: - """Return the speed of the sensor.""" - data = self.coordinator.data - return ( - float(data.download_speed) - if self.entity_description.key == "download" - else float(data.upload_speed) - ) - - -class TransmissionStatusSensor(TransmissionSensor): - """Representation of a Transmission status sensor.""" - - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - - @property - def native_value(self) -> str: - """Return the value of the status sensor.""" - upload = self.coordinator.data.upload_speed - download = self.coordinator.data.download_speed - if upload > 0 and download > 0: - return STATE_UP_DOWN - if upload > 0 and download == 0: - return STATE_SEEDING - if upload == 0 and download > 0: - return STATE_DOWNLOADING - return STATE_IDLE - - -class TransmissionTorrentsSensor(TransmissionSensor): - """Representation of a Transmission torrents sensor.""" - - MODES: dict[str, list[str] | None] = { - "started_torrents": ["downloading"], - "completed_torrents": ["seeding"], - "paused_torrents": ["stopped"], - "active_torrents": [ - "seeding", - "downloading", - ], - "total_torrents": None, - } - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return "Torrents" - - @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes, if any.""" - info = _torrents_info( - torrents=self.coordinator.torrents, - order=self.coordinator.order, - limit=self.coordinator.limit, - statuses=self.MODES[self.entity_description.key], - ) - return { - STATE_ATTR_TORRENT_INFO: info, - } + if attr_func := self.entity_description.extra_state_attr_func: + return attr_func(self.coordinator) + return None - @property - def native_value(self) -> int: - """Return the count of the sensor.""" - torrents = _filter_torrents( - self.coordinator.torrents, statuses=self.MODES[self.entity_description.key] - ) - return len(torrents) + +def get_state(upload: int, download: int) -> str: + """Get current download/upload state.""" + if upload > 0 and download > 0: + return STATE_UP_DOWN + if upload > 0 and download == 0: + return STATE_SEEDING + if upload == 0 and download > 0: + return STATE_DOWNLOADING + return STATE_IDLE def _filter_torrents( @@ -192,13 +206,13 @@ def _filter_torrents( ] -def _torrents_info( - torrents: list[Torrent], order: str, limit: int, statuses: list[str] | None = None +def _torrents_info_attr( + coordinator: TransmissionDataUpdateCoordinator, key: str ) -> dict[str, Any]: infos = {} - torrents = _filter_torrents(torrents, statuses) - torrents = SUPPORTED_ORDER_MODES[order](torrents) - for torrent in torrents[:limit]: + torrents = _filter_torrents(coordinator.torrents, MODES[key]) + torrents = SUPPORTED_ORDER_MODES[coordinator.order](torrents) + for torrent in torrents[: coordinator.limit]: info = infos[torrent.name] = { "added_date": torrent.added_date, "percent_done": f"{torrent.percent_done * 100:.2f}", @@ -207,4 +221,4 @@ def _torrents_info( } with suppress(ValueError): info["eta"] = str(torrent.eta) - return infos + return {STATE_ATTR_TORRENT_INFO: infos} From 0a6f541b27a5fdeef1081986f45bb2f394a39a73 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 19:14:10 +0100 Subject: [PATCH 486/927] Avoid mutating entity descriptions in screenlogic (#105983) --- .../components/screenlogic/binary_sensor.py | 10 ++++++---- homeassistant/components/screenlogic/sensor.py | 12 +++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 9192458dde4..eb808835c58 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,6 +1,6 @@ """Support for a ScreenLogic Binary Sensor.""" from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.common import ON_OFF @@ -32,14 +32,14 @@ from .util import cleanup_excluded_entity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass class ScreenLogicBinarySensorDescription( BinarySensorEntityDescription, ScreenLogicEntityDescription ): """A class that describes ScreenLogic binary sensor eneites.""" -@dataclass +@dataclasses.dataclass class ScreenLogicPushBinarySensorDescription( ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): @@ -261,5 +261,7 @@ class ScreenLogicPumpBinarySensor(ScreenLogicBinarySensor): pump_index: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 5d4efc55883..4cd14fb7a16 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,7 +1,7 @@ """Support for a ScreenLogic Sensor.""" from collections.abc import Callable from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE @@ -35,21 +35,21 @@ from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass class ScreenLogicSensorMixin: """Mixin for SecreenLogic sensor entity.""" value_mod: Callable[[int | str], int | str] | None = None -@dataclass +@dataclasses.dataclass class ScreenLogicSensorDescription( ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription ): """Describes a ScreenLogic sensor.""" -@dataclass +@dataclasses.dataclass class ScreenLogicPushSensorDescription( ScreenLogicSensorDescription, ScreenLogicPushEntityDescription ): @@ -336,7 +336,9 @@ class ScreenLogicPumpSensor(ScreenLogicSensor): pump_type: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) if entity_description.enabled_lambda: self._attr_entity_registry_enabled_default = ( From 93a9a9d1e23c3d6377e0681b7a524476788d3785 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 18 Dec 2023 19:31:37 +0100 Subject: [PATCH 487/927] Add Raspberry Pi 5 to version and hardware integration (#105992) --- homeassistant/components/hassio/__init__.py | 1 + homeassistant/components/raspberry_pi/hardware.py | 2 ++ homeassistant/components/version/const.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e7ab7aac3c8..3dd9b11ae64 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -270,6 +270,7 @@ HARDWARE_INTEGRATIONS = { "rpi3-64": "raspberry_pi", "rpi4": "raspberry_pi", "rpi4-64": "raspberry_pi", + "rpi5-64": "raspberry_pi", "yellow": "homeassistant_yellow", } diff --git a/homeassistant/components/raspberry_pi/hardware.py b/homeassistant/components/raspberry_pi/hardware.py index e90316ccb3c..2141ff6034d 100644 --- a/homeassistant/components/raspberry_pi/hardware.py +++ b/homeassistant/components/raspberry_pi/hardware.py @@ -17,6 +17,7 @@ BOARD_NAMES = { "rpi3-64": "Raspberry Pi 3", "rpi4": "Raspberry Pi 4 (32-bit)", "rpi4-64": "Raspberry Pi 4", + "rpi5-64": "Raspberry Pi 5", } MODELS = { @@ -28,6 +29,7 @@ MODELS = { "rpi3-64": "3", "rpi4": "4", "rpi4-64": "4", + "rpi5-64": "5", } diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 2dcb0028b27..0b39ecee604 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -66,6 +66,7 @@ BOARD_MAP: Final[dict[str, str]] = { "RaspberryPi 3 64bit": "rpi3-64", "RaspberryPi 4": "rpi4", "RaspberryPi 4 64bit": "rpi4-64", + "RaspberryPi 5": "rpi5-64", "ASUS Tinkerboard": "tinker", "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", @@ -112,6 +113,7 @@ VALID_IMAGES: Final = [ "raspberrypi3", "raspberrypi4-64", "raspberrypi4", + "raspberrypi5-64", "tinker", ] From 5175737b60befdd03ed8fbcdc6290dd4788c0cf1 Mon Sep 17 00:00:00 2001 From: Miguel Camba Date: Mon, 18 Dec 2023 19:48:00 +0100 Subject: [PATCH 488/927] Add Valve integration (#102184) * Add Valve integration. This adds the valve integration discussed in https://github.com/home-assistant/architecture/discussions/975 Most of the code is taken from the cover integration but simplified since valves can't tilt. There are a couple outstanding errors I'm not sure how to solve and prevents me from even making this commit without `--no-verify`. * Apply PR feedback * Apply more feedback: Intruduce the bare minimum * Remove file commited by mistake * Hopefully this fixes tests * Match cover's typing and mypy settings * Change some configuration files * Fix test * Increase code coverage a little * Code coverate inproved to 91% * 95% code coverage * Coverate up to 97% * Coverage 98% * Apply PR feedback * Even more feedback * Add line I shouldn't have removed * Derive closed/open state from current position * Hopefully last feedback * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Remove unnecesary translation * Remove unused method arguments * Complete code coverage * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Simplify tests * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Apply last feedback * Update tests/components/valve/test_init.py Co-authored-by: Martin Hjelmare * Update tests/components/valve/test_init.py Co-authored-by: Martin Hjelmare * Update tests/testing_config/custom_components/test/valve.py Co-authored-by: Martin Hjelmare * More feedback * Apply suggestion * And more feedback * Apply feedback * Remove commented code * Reverse logic to unindent * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Implement stop valve for Mock valve * Fix tests now that I've implemented stop_valve * Assert it's neither opening nor closing * Use current position instead * Avoid scheduling executor when opening or closing * Fix incorrect bitwise operation * Simplify toggle * Remove uneeded partial functions * Make is_last_toggle_direction_open private * Remove valve from test custom integration * Improve test coverage * Address review comments * Address review comments * Address review comments * Update homeassistant/components/valve/__init__.py Co-authored-by: Martin Hjelmare * Update tests --------- Co-authored-by: Martin Hjelmare Co-authored-by: Erik --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/valve/__init__.py | 270 ++++++++++++++ homeassistant/components/valve/manifest.json | 8 + homeassistant/components/valve/services.yaml | 45 +++ homeassistant/components/valve/strings.json | 54 +++ homeassistant/const.py | 6 + homeassistant/helpers/selector.py | 2 + mypy.ini | 10 + tests/components/valve/__init__.py | 1 + tests/components/valve/test_init.py | 355 +++++++++++++++++++ 11 files changed, 754 insertions(+) create mode 100644 homeassistant/components/valve/__init__.py create mode 100644 homeassistant/components/valve/manifest.json create mode 100644 homeassistant/components/valve/services.yaml create mode 100644 homeassistant/components/valve/strings.json create mode 100644 tests/components/valve/__init__.py create mode 100644 tests/components/valve/test_init.py diff --git a/.strict-typing b/.strict-typing index ce9d84204a5..7c2d9d8daf2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -366,6 +366,7 @@ homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.vacuum.* homeassistant.components.vallox.* +homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* diff --git a/CODEOWNERS b/CODEOWNERS index 4ad2a38fa04..c45293d03c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1403,6 +1403,8 @@ build.json @home-assistant/supervisor /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- /tests/components/vallox/ @andre-richter @slovdahl @viiru- +/homeassistant/components/valve/ @home-assistant/core +/tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py new file mode 100644 index 00000000000..9521d597303 --- /dev/null +++ b/homeassistant/components/valve/__init__.py @@ -0,0 +1,270 @@ +"""Support for Valve devices.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from enum import IntFlag, StrEnum +import logging +from typing import Any, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "valve" +SCAN_INTERVAL = timedelta(seconds=15) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +class ValveDeviceClass(StrEnum): + """Device class for valve.""" + + # Refer to the valve dev docs for device class descriptions + WATER = "water" + GAS = "gas" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ValveDeviceClass)) + + +# mypy: disallow-any-generics +class ValveEntityFeature(IntFlag): + """Supported features of the valve entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + + +ATTR_CURRENT_POSITION = "current_position" +ATTR_POSITION = "position" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Track states and offer events for valves.""" + component = hass.data[DOMAIN] = EntityComponent[ValveEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_OPEN_VALVE, {}, "async_handle_open_valve", [ValveEntityFeature.OPEN] + ) + + component.async_register_entity_service( + SERVICE_CLOSE_VALVE, {}, "async_handle_close_valve", [ValveEntityFeature.CLOSE] + ) + + component.async_register_entity_service( + SERVICE_SET_VALVE_POSITION, + { + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_valve_position", + [ValveEntityFeature.SET_POSITION], + ) + + component.async_register_entity_service( + SERVICE_STOP_VALVE, {}, "async_stop_valve", [ValveEntityFeature.STOP] + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE], + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[ValveEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[ValveEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass(frozen=True, kw_only=True) +class ValveEntityDescription(EntityDescription): + """A class that describes valve entities.""" + + device_class: ValveDeviceClass | None = None + reports_position: bool = False + + +class ValveEntity(Entity): + """Base class for valve entities.""" + + entity_description: ValveEntityDescription + _attr_current_valve_position: int | None = None + _attr_device_class: ValveDeviceClass | None + _attr_is_closed: bool | None = None + _attr_is_closing: bool | None = None + _attr_is_opening: bool | None = None + _attr_reports_position: bool + _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) + + __is_last_toggle_direction_open = True + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + if hasattr(self, "_attr_reports_position"): + return self._attr_reports_position + if hasattr(self, "entity_description"): + return self.entity_description.reports_position + raise ValueError(f"'reports_position' not set for {self.entity_id}.") + + @property + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._attr_current_valve_position + + @property + def device_class(self) -> ValveDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + @final + def state(self) -> str | None: + """Return the state of the valve.""" + reports_position = self.reports_position + if self.is_opening: + self.__is_last_toggle_direction_open = True + return STATE_OPENING + if self.is_closing: + self.__is_last_toggle_direction_open = False + return STATE_CLOSING + if reports_position is True: + if (current_valve_position := self.current_valve_position) is None: + return None + position_zero = current_valve_position == 0 + return STATE_CLOSED if position_zero else STATE_OPEN + if (closed := self.is_closed) is None: + return None + return STATE_CLOSED if closed else STATE_OPEN + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + + return {ATTR_CURRENT_POSITION: self.current_valve_position} + + @property + def supported_features(self) -> ValveEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._attr_is_opening + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._attr_is_closing + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed or not.""" + return self._attr_is_closed + + def open_valve(self) -> None: + """Open the valve.""" + raise NotImplementedError() + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job(self.open_valve) + + @final + async def async_handle_open_valve(self) -> None: + """Open the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(100) + await self.async_open_valve() + + def close_valve(self) -> None: + """Close valve.""" + raise NotImplementedError() + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.hass.async_add_executor_job(self.close_valve) + + @final + async def async_handle_close_valve(self) -> None: + """Close the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(0) + await self.async_close_valve() + + async def async_toggle(self) -> None: + """Toggle the entity.""" + if self.supported_features & ValveEntityFeature.STOP and ( + self.is_closing or self.is_opening + ): + return await self.async_stop_valve() + if self.is_closed: + return await self.async_handle_open_valve() + if self.__is_last_toggle_direction_open: + return await self.async_handle_close_valve() + return await self.async_handle_open_valve() + + def set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + raise NotImplementedError() + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.hass.async_add_executor_job(self.set_valve_position, position) + + def stop_valve(self) -> None: + """Stop the valve.""" + raise NotImplementedError() + + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.hass.async_add_executor_job(self.stop_valve) diff --git a/homeassistant/components/valve/manifest.json b/homeassistant/components/valve/manifest.json new file mode 100644 index 00000000000..28563f0976c --- /dev/null +++ b/homeassistant/components/valve/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "valve", + "name": "Valve", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/valve", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/valve/services.yaml b/homeassistant/components/valve/services.yaml new file mode 100644 index 00000000000..936599818f1 --- /dev/null +++ b/homeassistant/components/valve/services.yaml @@ -0,0 +1,45 @@ +# Describes the format for available valve services + +open_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.OPEN + +close_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.CLOSE + +toggle: + target: + entity: + domain: valve + supported_features: + - - valve.ValveEntityFeature.CLOSE + - valve.ValveEntityFeature.OPEN + +set_valve_position: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.SET_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + +stop_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.STOP diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json new file mode 100644 index 00000000000..b86ec371b34 --- /dev/null +++ b/homeassistant/components/valve/strings.json @@ -0,0 +1,54 @@ +{ + "title": "Valve", + "entity_component": { + "_": { + "name": "[%key:component::valve::title%]", + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + } + } + }, + "water": { + "name": "Water" + }, + "gas": { + "name": "Gas" + } + }, + "services": { + "open_valve": { + "name": "[%key:common::action::open%]", + "description": "Opens a valve." + }, + "close_valve": { + "name": "[%key:common::action::close%]", + "description": "Closes a valve." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a valve open/closed." + }, + "set_valve_position": { + "name": "Set position", + "description": "Moves a valve to a specific position.", + "fields": { + "position": { + "name": "Position", + "description": "Target position." + } + } + }, + "stop_valve": { + "name": "[%key:common::action::stop%]", + "description": "Stops the valve movement." + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index df68e3ab05a..40b66b6aed3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -58,6 +58,7 @@ class Platform(StrEnum): TODO = "todo" TTS = "tts" VACUUM = "vacuum" + VALVE = "valve" UPDATE = "update" WAKE_WORD = "wake_word" WATER_HEATER = "water_heater" @@ -1105,6 +1106,11 @@ SERVICE_STOP_COVER: Final = "stop_cover" SERVICE_STOP_COVER_TILT: Final = "stop_cover_tilt" SERVICE_TOGGLE_COVER_TILT: Final = "toggle_cover_tilt" +SERVICE_CLOSE_VALVE: Final = "close_valve" +SERVICE_OPEN_VALVE: Final = "open_valve" +SERVICE_SET_VALVE_POSITION: Final = "set_valve_position" +SERVICE_STOP_VALVE: Final = "stop_valve" + SERVICE_SELECT_OPTION: Final = "select_option" # #### API / REMOTE #### diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ab22877f906..9c4266583e8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -102,6 +102,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.todo import TodoListEntityFeature from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.components.weather import WeatherEntityFeature @@ -122,6 +123,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "TodoListEntityFeature": TodoListEntityFeature, "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, + "ValveEntityFeature": ValveEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, "WeatherEntityFeature": WeatherEntityFeature, } diff --git a/mypy.ini b/mypy.ini index 7325c7fd357..45395463ce9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3423,6 +3423,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.valve.*] +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.velbus.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/valve/__init__.py b/tests/components/valve/__init__.py new file mode 100644 index 00000000000..c39ec8220af --- /dev/null +++ b/tests/components/valve/__init__.py @@ -0,0 +1 @@ +"""Tests for the valve component.""" diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py new file mode 100644 index 00000000000..08b0771da8e --- /dev/null +++ b/tests/components/valve/test_init.py @@ -0,0 +1,355 @@ +"""The tests for Valve.""" +from collections.abc import Generator + +import pytest + +from homeassistant.components.valve import ( + DOMAIN, + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SET_VALVE_POSITION, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +class MockValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + _attr_should_poll = False + _target_valve_position: int + + def __init__( + self, + unique_id: str = "mock_valve", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + current_position: int = None, + device_class: ValveDeviceClass = None, + reports_position: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_current_valve_position = current_position + if reports_position is not None: + self._attr_reports_position = reports_position + if device_class is not None: + self._attr_device_class = device_class + + def set_valve_position(self, position: int) -> None: + """Set the valve to opening or closing towards a target percentage.""" + if position > self._attr_current_valve_position: + self._attr_is_closing = False + self._attr_is_opening = True + else: + self._attr_is_closing = True + self._attr_is_opening = False + self._target_valve_position = position + self.schedule_update_ha_state() + + def stop_valve(self) -> None: + """Stop the valve.""" + self._attr_is_closing = False + self._attr_is_opening = False + self._target_valve_position = None + self._attr_is_closed = self._attr_current_valve_position == 0 + self.schedule_update_ha_state() + + @callback + def finish_movement(self): + """Set the value to the saved target and removes intermediate states.""" + self._attr_current_valve_position = self._target_valve_position + self._attr_is_closing = False + self._attr_is_opening = False + self.async_write_ha_state() + + +class MockBinaryValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + def __init__( + self, + unique_id: str = "mock_valve_2", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + is_closed: bool = None, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_is_closed = is_closed + self._attr_reports_position = False + + def open_valve(self) -> None: + """Open the valve.""" + self._attr_is_closed = False + + def close_valve(self) -> None: + """Mock implementantion for sync close function.""" + self._attr_is_closed = True + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture +def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: + """Mock a config entry which sets up a couple of valve entities.""" + entities = [ + MockBinaryValveEntity( + is_closed=False, + features=ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + MockValveEntity( + current_position=50, + features=ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ] + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, Platform.VALVE + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_unload_platforms(config_entry, [Platform.VALVE]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + + return (config_entry, entities) + + +async def test_valve_setup( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test setup and tear down of valve platform and entity.""" + config_entry = mock_config_entry[0] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = mock_config_entry[1][0].entity_id + + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + entity_state = hass.states.get(entity_id) + + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + +async def test_services( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test the provided services.""" + config_entry = mock_config_entry[0] + ent1, ent2 = mock_config_entry[1] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test init all valves should be open + assert is_open(hass, ent1) + assert is_open(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities without stop should be closed and with stop should be closing + assert is_closed(hass, ent1) + assert is_closing(hass, ent2) + ent2.finish_movement() + assert is_closed(hass, ent2) + + # call basic toggle services and set different valve position states + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + await hass.async_block_till_done() + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_open(hass, ent1) + assert is_opening(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_closed(hass, ent1) + assert not is_opening(hass, ent2) + assert not is_closing(hass, ent2) + assert is_closed(hass, ent2) + + await call_service(hass, SERVICE_SET_VALVE_POSITION, ent2, 50) + assert is_opening(hass, ent2) + + +async def test_valve_device_class(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity() + default_valve.hass = hass + + assert default_valve.device_class is None + + entity_description = ValveEntityDescription( + key="test", + device_class=ValveDeviceClass.GAS, + ) + default_valve.entity_description = entity_description + assert default_valve.device_class is ValveDeviceClass.GAS + + water_valve = MockValveEntity(device_class=ValveDeviceClass.WATER) + water_valve.hass = hass + + assert water_valve.device_class is ValveDeviceClass.WATER + + +async def test_valve_report_position(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity(reports_position=None) + default_valve.hass = hass + + with pytest.raises(ValueError): + default_valve.reports_position + + second_valve = MockValveEntity(reports_position=True) + second_valve.hass = hass + + assert second_valve.reports_position is True + + entity_description = ValveEntityDescription(key="test", reports_position=True) + third_valve = MockValveEntity(reports_position=None) + third_valve.entity_description = entity_description + assert third_valve.reports_position is True + + +async def test_none_state(hass: HomeAssistant) -> None: + """Test different criteria for closeness.""" + binary_valve_with_none_is_closed_attr = MockBinaryValveEntity(is_closed=None) + binary_valve_with_none_is_closed_attr.hass = hass + + assert binary_valve_with_none_is_closed_attr.state is None + + pos_valve_with_none_is_closed_attr = MockValveEntity() + pos_valve_with_none_is_closed_attr.hass = hass + + assert pos_valve_with_none_is_closed_attr.state is None + + +async def test_supported_features(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + valve = MockValveEntity(features=None) + valve.hass = hass + + assert valve.supported_features is None + + +def call_service(hass, service, ent, position=None): + """Call any service on entity.""" + params = {ATTR_ENTITY_ID: ent.entity_id} + if position is not None: + params["position"] = position + return hass.services.async_call(DOMAIN, service, params, blocking=True) + + +def set_valve_position(ent, position) -> None: + """Set a position value to a valve.""" + ent._values["current_valve_position"] = position + + +def is_open(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPEN) + + +def is_opening(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPENING) + + +def is_closed(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSED) + + +def is_closing(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSING) From 27f81b3f631dadbecd42d9ddfc640c9e70038d88 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 20:58:32 +0100 Subject: [PATCH 489/927] Avoid mutating entity descriptions in unifiprotect (#105976) --- .../components/unifiprotect/binary_sensor.py | 19 +++++++++++-------- .../components/unifiprotect/models.py | 8 +++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 8f8bcab8ede..0be18249e31 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,8 +1,7 @@ """Component providing binary sensors for UniFi Protect.""" from __future__ import annotations -from copy import copy -from dataclasses import dataclass +import dataclasses import logging from pyunifiprotect.data import ( @@ -43,14 +42,14 @@ _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" -@dataclass +@dataclasses.dataclass class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclass +@dataclasses.dataclass class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): @@ -561,9 +560,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( updated_device.mount_type, BinarySensorDeviceClass.DOOR ) + else: + self._attr_device_class = self.entity_description.device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): @@ -584,9 +585,11 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): # backwards compat with old unique IDs index = self._disk.slot - 1 - description = copy(description) - description.key = f"{description.key}_{index}" - description.name = f"{disk.type} {disk.slot}" + description = dataclasses.replace( + description, + key=f"{description.key}_{index}", + name=f"{disk.type} {disk.slot}", + ) super().__init__(data, device, description) @callback diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c250a021340..9f57b92163c 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -52,9 +52,11 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): def __post_init__(self) -> None: """Pre-convert strings to tuples for faster get_nested_attr.""" - self.ufp_required_field = split_tuple(self.ufp_required_field) - self.ufp_value = split_tuple(self.ufp_value) - self.ufp_enabled = split_tuple(self.ufp_enabled) + object.__setattr__( + self, "ufp_required_field", split_tuple(self.ufp_required_field) + ) + object.__setattr__(self, "ufp_value", split_tuple(self.ufp_value)) + object.__setattr__(self, "ufp_enabled", split_tuple(self.ufp_enabled)) def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" From 1d1cd6be575585da2563309f103cdc4c2df93e08 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 18 Dec 2023 21:03:40 +0100 Subject: [PATCH 490/927] Avoid mutating entity descriptions in sunweg (#105982) --- homeassistant/components/sunweg/__init__.py | 47 ++++----- homeassistant/components/sunweg/sensor.py | 28 +++--- tests/components/sunweg/test_init.py | 102 +++++++++++++------- 3 files changed, 105 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py index cdf7cc123fc..9da91ccda0f 100644 --- a/homeassistant/components/sunweg/__init__.py +++ b/homeassistant/components/sunweg/__init__.py @@ -10,11 +10,10 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import StateType, UndefinedType from homeassistant.util import Throttle from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType -from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription SCAN_INTERVAL = datetime.timedelta(minutes=5) @@ -102,24 +101,30 @@ class SunWEGData: def get_data( self, - entity_description: SunWEGSensorEntityDescription, + *, + api_variable_key: str, + api_variable_unit: str | None, + deep_name: str | None, device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ) -> StateType | datetime.datetime: + inverter_id: int, + name: str | UndefinedType | None, + native_unit_of_measurement: str | None, + never_resets: bool, + previous_value_drop_threshold: float | None, + ) -> tuple[StateType | datetime.datetime, str | None]: """Get the data.""" _LOGGER.debug( "Data request for: %s", - entity_description.name, + name, ) - variable = entity_description.api_variable_key - previous_unit = entity_description.native_unit_of_measurement + variable = api_variable_key + previous_unit = native_unit_of_measurement api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) previous_value = self.previous_values.get(variable) return_value = api_value - if entity_description.api_variable_unit is not None: - entity_description.native_unit_of_measurement = self.get_api_value( - entity_description.api_variable_unit, + if api_variable_unit is not None: + native_unit_of_measurement = self.get_api_value( + api_variable_unit, device_type, inverter_id, deep_name, @@ -127,18 +132,18 @@ class SunWEGData: # If we have a 'drop threshold' specified, then check it and correct if needed if ( - entity_description.previous_value_drop_threshold is not None + previous_value_drop_threshold is not None and previous_value is not None and api_value is not None - and previous_unit == entity_description.native_unit_of_measurement + and previous_unit == native_unit_of_measurement ): _LOGGER.debug( ( "%s - Drop threshold specified (%s), checking for drop... API" " Value: %s, Previous Value: %s" ), - entity_description.name, - entity_description.previous_value_drop_threshold, + name, + previous_value_drop_threshold, api_value, previous_value, ) @@ -149,7 +154,7 @@ class SunWEGData: # Note - The energy dashboard takes care of drops within 10% # of the current value, however if the value is low e.g. 0.2 # and drops by 0.1 it classes as a reset. - if -(entity_description.previous_value_drop_threshold) <= diff < 0: + if -(previous_value_drop_threshold) <= diff < 0: _LOGGER.debug( ( "Diff is negative, but only by a small amount therefore not a" @@ -161,9 +166,7 @@ class SunWEGData: ) return_value = previous_value else: - _LOGGER.debug( - "%s - No drop detected, using API value", entity_description.name - ) + _LOGGER.debug("%s - No drop detected, using API value", name) # Lifetime total values should always be increasing, they will never reset, # however the API sometimes returns 0 values when the clock turns to 00:00 @@ -178,7 +181,7 @@ class SunWEGData: # - Previous value will not exist meaning 0 will be returned # - This is an edge case that would be better handled by looking # up the previous value of the entity from the recorder - if entity_description.never_resets and api_value == 0 and previous_value: + if never_resets and api_value == 0 and previous_value: _LOGGER.debug( ( "API value is 0, but this value should never reset, returning" @@ -190,4 +193,4 @@ class SunWEGData: self.previous_values[variable] = return_value - return return_value + return (return_value, native_unit_of_measurement) diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py index 5759e3a6251..42a3dc33d2b 100644 --- a/homeassistant/components/sunweg/sensor.py +++ b/homeassistant/components/sunweg/sensor.py @@ -1,7 +1,6 @@ """Read status of SunWEG inverters.""" from __future__ import annotations -import datetime import logging from types import MappingProxyType from typing import Any @@ -16,7 +15,6 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from . import SunWEGData from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType @@ -160,18 +158,20 @@ class SunWEGInverter(SensorEntity): name=name, ) - @property - def native_value( - self, - ) -> StateType | datetime.datetime: - """Return the state of the sensor.""" - return self.probe.get_data( - self.entity_description, - device_type=self.device_type, - inverter_id=self.inverter_id, - deep_name=self.deep_name, - ) - def update(self) -> None: """Get the latest data from the Sun WEG API and updates the state.""" self.probe.update() + ( + self._attr_native_value, + self._attr_native_unit_of_measurement, + ) = self.probe.get_data( + api_variable_key=self.entity_description.api_variable_key, + api_variable_unit=self.entity_description.api_variable_unit, + deep_name=self.deep_name, + device_type=self.device_type, + inverter_id=self.inverter_id, + name=self.entity_description.name, + native_unit_of_measurement=self.native_unit_of_measurement, + never_resets=self.entity_description.never_resets, + previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, + ) diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 12c482f6b53..ca191469377 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -95,26 +95,41 @@ async def test_sunwegdata_get_data_drop_threshold() -> None: ) entity_description.previous_value_drop_threshold = 0.1 data.get_api_value.return_value = 3.0 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 3.0 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) data.get_api_value.return_value = 2.91 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 3.0 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) data.get_api_value.return_value = 2.8 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 2.8 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) async def test_sunwegdata_get_data_never_reset() -> None: @@ -127,23 +142,38 @@ async def test_sunwegdata_get_data_never_reset() -> None: ) entity_description.never_resets = True data.get_api_value.return_value = 3.0 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 3.0 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) data.get_api_value.return_value = 0 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 3.0 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) data.get_api_value.return_value = 2.8 - assert ( - data.get_data( - entity_description=entity_description, device_type=DeviceType.TOTAL - ) - == 2.8 - ) + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) From b96d2cadac643781b0db30ebc65d8725f49f4e75 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 18 Dec 2023 21:06:02 +0100 Subject: [PATCH 491/927] Add new price sensors with API token access to pvpc hourly pricing (#85769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Implement optional API token in config-flow + options to make the data download from an authenticated path in ESIOS server As this is an *alternative* access, and current public path works for the PVPC, no user (current or new) is compelled to obtain a token, and it can be enabled anytime in options, or doing the setup again When enabling the token, it is verified (or "invalid_auth" error), and a 'reauth' flow is implemented, which can change or disable the token if it starts failing. The 1st step of config/options flow adds a bool to enable this private access, - if unchecked (default), entry is set for public access (like before) - if checked, a 2nd step opens to input the token, with instructions of how to get one (with a direct link to create a 'request email'). If the token is valid, the entry is set for authenticated access The 'reauth' flow shows the boolean flag so the user could disable a bad token by unchecking the boolean flag 'use_api_token' * ♻️ Remove storage of flag 'use_api_token' in config entry leaving it only to enable/disable the optional token in the config-flow * ♻️ Adjust async_update_options * ✨ Add new price sensors with API token access New price sensors added: - Injection price: price of excess energy from self-consumption - OMIE price: electricity price in the 'open' market - MAG price: Temporal tax cost for gas compensation * ✅ Adapt tests to work with multiple sensors * 🐛 Fix all integration sensors going unavailable when any sensor lacks data for the current day (usually the 'OMIE price') * Fix rebase * Customize icons and display precision for new sensors * Disable MAG Tax and OMIE price sensors by default * Move logic to assign sensor unique ids to integration * Move helper functions to helpers.py * Fix sensor activation for API download --- .../pvpc_hourly_pricing/__init__.py | 16 ++++- .../components/pvpc_hourly_pricing/const.py | 2 +- .../components/pvpc_hourly_pricing/helpers.py | 49 +++++++++++++++ .../components/pvpc_hourly_pricing/sensor.py | 62 +++++++++++++++++-- .../pvpc_hourly_pricing/conftest.py | 48 +++++++------- .../pvpc_hourly_pricing/test_config_flow.py | 49 +++++++++++---- 6 files changed, 181 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/pvpc_hourly_pricing/helpers.py diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 7071000ffd9..00a3a355477 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -10,10 +10,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN +from .helpers import get_enabled_sensor_keys _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -22,7 +24,12 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" - coordinator = ElecPricesDataUpdateCoordinator(hass, entry) + entity_registry = er.async_get(hass) + sensor_keys = get_enabled_sensor_keys( + using_private_api=entry.data.get(CONF_API_TOKEN) is not None, + entries=er.async_entries_for_config_entry(entity_registry, entry.entry_id), + ) + coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -55,7 +62,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + ) -> None: """Initialize.""" self.api = PVPCData( session=async_get_clientsession(hass), @@ -64,6 +73,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): power=entry.data[ATTR_POWER], power_valley=entry.data[ATTR_POWER_P3], api_token=entry.data.get(CONF_API_TOKEN), + sensor_keys=tuple(sensor_keys), ) super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) @@ -84,7 +94,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): if ( not api_data or not api_data.sensors - or not all(api_data.availability.values()) + or not any(api_data.availability.values()) ): raise UpdateFailed return api_data diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py index ea4d97620ec..a6bfc6f3188 100644 --- a/homeassistant/components/pvpc_hourly_pricing/const.py +++ b/homeassistant/components/pvpc_hourly_pricing/const.py @@ -1,5 +1,5 @@ """Constant values for pvpc_hourly_pricing.""" -from aiopvpc import TARIFFS +from aiopvpc.const import TARIFFS import voluptuous as vol DOMAIN = "pvpc_hourly_pricing" diff --git a/homeassistant/components/pvpc_hourly_pricing/helpers.py b/homeassistant/components/pvpc_hourly_pricing/helpers.py new file mode 100644 index 00000000000..195d20aee89 --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/helpers.py @@ -0,0 +1,49 @@ +"""Helper functions to relate sensors keys and unique ids.""" +from aiopvpc.const import ( + ALL_SENSORS, + KEY_INJECTION, + KEY_MAG, + KEY_OMIE, + KEY_PVPC, + TARIFFS, +) + +from homeassistant.helpers.entity_registry import RegistryEntry + +_ha_uniqueid_to_sensor_key = { + TARIFFS[0]: KEY_PVPC, + TARIFFS[1]: KEY_PVPC, + f"{TARIFFS[0]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[1]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[0]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[1]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[0]}_{KEY_OMIE}": KEY_OMIE, + f"{TARIFFS[1]}_{KEY_OMIE}": KEY_OMIE, +} + + +def get_enabled_sensor_keys( + using_private_api: bool, entries: list[RegistryEntry] +) -> set[str]: + """Get enabled API indicators.""" + if not using_private_api: + return {KEY_PVPC} + if len(entries) > 1: + # activate only enabled sensors + return { + _ha_uniqueid_to_sensor_key[sensor.unique_id] + for sensor in entries + if not sensor.disabled + } + # default sensors when enabling token access + return {KEY_PVPC, KEY_INJECTION} + + +def make_sensor_unique_id(config_entry_id: str | None, sensor_key: str) -> str: + """Generate unique_id for each sensor kind and config entry.""" + assert sensor_key in ALL_SENSORS + assert config_entry_id is not None + if sensor_key == KEY_PVPC: + # for old compatibility + return config_entry_id + return f"{config_entry_id}_{sensor_key}" diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 3368b24b3ff..9cc3ef35a4b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -6,6 +6,8 @@ from datetime import datetime import logging from typing import Any +from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -22,19 +24,49 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ElecPricesDataUpdateCoordinator from .const import DOMAIN +from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="PVPC", + key=KEY_PVPC, icon="mdi:currency-eur", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, name="PVPC", ), + SensorEntityDescription( + key=KEY_INJECTION, + icon="mdi:transmission-tower-export", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="Injection Price", + ), + SensorEntityDescription( + key=KEY_MAG, + icon="mdi:bank-transfer", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="MAG tax", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=KEY_OMIE, + icon="mdi:shopping", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="OMIE Price", + entity_registry_enabled_default=False, + ), ) _PRICE_SENSOR_ATTRIBUTES_MAP = { + "data_id": "data_id", + "name": "data_name", "tariff": "tariff", "period": "period", "available_power": "available_power", @@ -119,7 +151,11 @@ async def async_setup_entry( ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)]) + sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] + if coordinator.api.using_private_api: + for sensor_desc in SENSOR_TYPES[1:]: + sensors.append(ElecPriceSensor(coordinator, sensor_desc, entry.unique_id)) + async_add_entities(sensors) class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], SensorEntity): @@ -137,7 +173,7 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor super().__init__(coordinator) self.entity_description = description self._attr_attribution = coordinator.api.attribution - self._attr_unique_id = unique_id + self._attr_unique_id = make_sensor_unique_id(unique_id, description.key) self._attr_device_info = DeviceInfo( configuration_url="https://api.esios.ree.es", entry_type=DeviceEntryType.SERVICE, @@ -146,9 +182,23 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor name="ESIOS", ) + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.availability.get( + self.entity_description.key, False + ) + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() + # Enable API downloads for this sensor + self.coordinator.api.update_active_sensors(self.entity_description.key, True) + self.async_on_remove( + lambda: self.coordinator.api.update_active_sensors( + self.entity_description.key, False + ) + ) # Update 'state' value in hour changes self.async_on_remove( @@ -157,10 +207,10 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor ) ) _LOGGER.debug( - "Setup of price sensor %s (%s) with tariff '%s'", - self.name, + "Setup of ESIOS sensor %s (%s, unique_id: %s)", + self.entity_description.key, self.entity_id, - self.coordinator.api.tariff, + self._attr_unique_id, ) @callback diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index efe15547c13..3bf1b08a51d 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -11,6 +11,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_JSON_PUBLIC_DATA_2023_01_06 = "PVPC_DATA_2023_01_06.json" FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06 = "PRICES_ESIOS_1001_2023_01_06.json" +_ESIOS_INDICATORS_FOR_EACH_SENSOR = ("1001", "1739", "1900", "10211") def check_valid_state(state, tariff: str, value=None, key_attr=None): @@ -43,18 +44,19 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): "https://api.esios.ree.es/archives/70/download_json?locale=es&date={0}" ) mask_url_esios = ( - "https://api.esios.ree.es/indicators/1001" - "?start_date={0}T00:00&end_date={0}T23:59" + "https://api.esios.ree.es/indicators/{0}" + "?start_date={1}T00:00&end_date={1}T23:59" ) example_day = "2023-01-06" aioclient_mock.get( mask_url_public.format(example_day), text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_PUBLIC_DATA_2023_01_06}"), ) - aioclient_mock.get( - mask_url_esios.format(example_day), - text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, example_day), + text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), + ) # simulate missing days aioclient_mock.get( @@ -62,22 +64,24 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): status=HTTPStatus.OK, text='{"message":"No values for specified archive"}', ) - aioclient_mock.get( - mask_url_esios.format("2023-01-07"), - status=HTTPStatus.OK, - text=( - '{"indicator":{"name":"Término de facturación de energía activa del ' - 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' - '"step_type":"linear","disaggregated":true,"magnitud":' - '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' - '"values_updated_at":null,"values":[]}}' - ), - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "2023-01-07"), + status=HTTPStatus.OK, + text=( + '{"indicator":{"name":"Término de facturación de energía activa del ' + 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' + '"step_type":"linear","disaggregated":true,"magnitud":' + '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' + '"values_updated_at":null,"values":[]}}' + ).replace("1001", esios_ind), + ) # simulate bad authentication - aioclient_mock.get( - mask_url_esios.format("2023-01-08"), - status=HTTPStatus.UNAUTHORIZED, - text="HTTP Token: Access denied.", - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "2023-01-08"), + status=HTTPStatus.UNAUTHORIZED, + text="HTTP Token: Access denied.", + ) return aioclient_mock diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 950aea8e32c..087edcc1557 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -64,6 +64,10 @@ async def test_config_flow( check_valid_state(state, tariff=TARIFFS[1]) assert pvpc_aioclient_mock.call_count == 1 + # no extra sensors created without enabled API token + state_inyection = hass.states.get("sensor.injection_price") + assert state_inyection is None + # Check abort when configuring another with same tariff result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -117,18 +121,27 @@ async def test_config_flow( assert pvpc_aioclient_mock.call_count == 2 result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert pvpc_aioclient_mock.call_count == 2 await hass.async_block_till_done() state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 3 + assert pvpc_aioclient_mock.call_count == 4 assert state.attributes["period"] == "P3" assert state.attributes["next_period"] == "P2" assert state.attributes["available_power"] == 4600 + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + assert state_inyection + assert not state_mag + assert not state_omie + assert "period" not in state_inyection.attributes + assert "available_power" not in state_inyection.attributes + # check update failed freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) @@ -136,7 +149,7 @@ async def test_config_flow( state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 # disable api token in options result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -148,8 +161,18 @@ async def test_config_flow( user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False}, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 7 + + state = hass.states.get("sensor.esios_pvpc") + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + check_valid_state(state, tariff=TARIFFS[1]) + assert state_inyection.state == "unavailable" + assert not state_mag + assert not state_omie async def test_reauth( @@ -181,7 +204,7 @@ async def test_reauth( assert pvpc_aioclient_mock.call_count == 0 result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "api_token" @@ -190,17 +213,17 @@ async def test_reauth( freezer.move_to(_MOCK_TIME_VALID_RESPONSES) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY config_entry = result["result"] - assert pvpc_aioclient_mock.call_count == 3 + assert pvpc_aioclient_mock.call_count == 4 # check reauth trigger with bad-auth responses freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) async_fire_time_changed(hass, _MOCK_TIME_BAD_AUTH_RESPONSES) await hass.async_block_till_done() - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] assert result["context"]["entry_id"] == config_entry.entry_id @@ -208,11 +231,11 @@ async def test_reauth( assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert pvpc_aioclient_mock.call_count == 5 + assert pvpc_aioclient_mock.call_count == 7 result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] assert result["context"]["entry_id"] == config_entry.entry_id @@ -222,11 +245,11 @@ async def test_reauth( freezer.move_to(_MOCK_TIME_VALID_RESPONSES) async_fire_time_changed(hass, _MOCK_TIME_VALID_RESPONSES) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert pvpc_aioclient_mock.call_count == 6 + assert pvpc_aioclient_mock.call_count == 8 await hass.async_block_till_done() - assert pvpc_aioclient_mock.call_count == 7 + assert pvpc_aioclient_mock.call_count == 10 From b35afccdb7293bc3444d94a7046bc053ed069663 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 18 Dec 2023 21:11:06 +0100 Subject: [PATCH 492/927] Add PLC PHY rates as sensor to devolo Home Network (#87039) * Add plc phyrate sensors * Fix mypy * Add tests * Use suggested_display_precision * Adapt to recent development * Remove accidentally added constant * Fix tests * Fix pylint * Use PHY rate instead of phyrate * Adapt tests * Hopefully fix mypy * Hopefully fix mypy * Use LogicalNetwork * Apply mypy fixes --- .../components/devolo_home_network/const.py | 2 + .../components/devolo_home_network/entity.py | 3 +- .../components/devolo_home_network/sensor.py | 133 ++++++++++++++++-- .../devolo_home_network/strings.json | 6 + tests/components/devolo_home_network/const.py | 43 +++++- .../snapshots/test_sensor.ambr | 96 +++++++++++++ .../devolo_home_network/test_sensor.py | 78 ++++++++++ 7 files changed, 340 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index aaee8051cb5..4caa4f5b60b 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -25,6 +25,8 @@ IDENTIFY = "identify" IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +PLC_RX_RATE = "plc_rx_rate" +PLC_TX_RATE = "plc_tx_rate" REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a0aa0466d90..d6ddf661494 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -9,7 +9,7 @@ from devolo_plc_api.device_api import ( NeighborAPInfo, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -25,6 +25,7 @@ _DataT = TypeVar( "_DataT", bound=( LogicalNetwork + | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 7a6da1f41a5..5d2b768d547 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -3,19 +3,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from typing import Any, Generic, TypeVar from devolo_plc_api.device import Device from devolo_plc_api.device_api import ConnectedStationInfo, NeighborAPInfo -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import REMOTE, DataRate, LogicalNetwork from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,25 +27,38 @@ from .const import ( CONNECTED_WIFI_CLIENTS, DOMAIN, NEIGHBORING_WIFI_NETWORKS, + PLC_RX_RATE, + PLC_TX_RATE, ) from .entity import DevoloCoordinatorEntity -_DataT = TypeVar( - "_DataT", - bound=LogicalNetwork | list[ConnectedStationInfo] | list[NeighborAPInfo], +_CoordinatorDataT = TypeVar( + "_CoordinatorDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], +) +_ValueDataT = TypeVar( + "_ValueDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], ) +class DataRateDirection(StrEnum): + """Direction of data transfer.""" + + RX = "rx_rate" + TX = "tx_rate" + + @dataclass -class DevoloSensorRequiredKeysMixin(Generic[_DataT]): +class DevoloSensorRequiredKeysMixin(Generic[_CoordinatorDataT]): """Mixin for required keys.""" - value_func: Callable[[_DataT], int] + value_func: Callable[[_CoordinatorDataT], float] @dataclass class DevoloSensorEntityDescription( - SensorEntityDescription, DevoloSensorRequiredKeysMixin[_DataT] + SensorEntityDescription, DevoloSensorRequiredKeysMixin[_CoordinatorDataT] ): """Describes devolo sensor entity.""" @@ -71,6 +86,24 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { icon="mdi:wifi-marker", value_func=len, ), + PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_RX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC downlink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.RX, 0), + suggested_display_precision=0, + ), + PLC_TX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_TX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC uplink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.TX, 0), + suggested_display_precision=0, + ), } @@ -83,7 +116,7 @@ async def async_setup_entry( entry.entry_id ]["coordinators"] - entities: list[DevoloSensorEntity[Any]] = [] + entities: list[BaseDevoloSensorEntity[Any, Any]] = [] if device.plcnet: entities.append( DevoloSensorEntity( @@ -93,6 +126,29 @@ async def async_setup_entry( device, ) ) + network = await device.plcnet.async_get_network_overview() + peers = [ + peer.mac_address for peer in network.devices if peer.topology == REMOTE + ] + for peer in peers: + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_TX_RATE], + device, + peer, + ) + ) + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_RX_RATE], + device, + peer, + ) + ) if device.device and "wifi1" in device.device.features: entities.append( DevoloSensorEntity( @@ -113,23 +169,70 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): +class BaseDevoloSensorEntity( + Generic[_CoordinatorDataT, _ValueDataT], + DevoloCoordinatorEntity[_CoordinatorDataT], + SensorEntity, +): """Representation of a devolo sensor.""" - entity_description: DevoloSensorEntityDescription[_DataT] - def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], - description: DevoloSensorEntityDescription[_DataT], + coordinator: DataUpdateCoordinator[_CoordinatorDataT], + description: DevoloSensorEntityDescription[_ValueDataT], device: Device, ) -> None: """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) + +class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): + """Representation of a generic devolo sensor.""" + + entity_description: DevoloSensorEntityDescription[_CoordinatorDataT] + @property - def native_value(self) -> int: + def native_value(self) -> float: """State of the sensor.""" return self.entity_description.value_func(self.coordinator.data) + + +class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataRate]): + """Representation of a devolo PLC data rate sensor.""" + + entity_description: DevoloSensorEntityDescription[DataRate] + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[LogicalNetwork], + description: DevoloSensorEntityDescription[DataRate], + device: Device, + peer: str, + ) -> None: + """Initialize entity.""" + super().__init__(entry, coordinator, description, device) + self._peer = peer + peer_device = next( + device + for device in self.coordinator.data.devices + if device.mac_address == peer + ) + + self._attr_unique_id = f"{self._attr_unique_id}_{peer}" + self._attr_name = f"{description.name} ({peer_device.user_device_name})" + self._attr_entity_registry_enabled_default = peer_device.attached_to_router + + @property + def native_value(self) -> float: + """State of the sensor.""" + return self.entity_description.value_func( + next( + data_rate + for data_rate in self.coordinator.data.data_rates + if data_rate.mac_address_from == self.device.mac + and data_rate.mac_address_to == self._peer + ) + ) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 55a7920ab3e..1362417c125 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -62,6 +62,12 @@ }, "neighboring_wifi_networks": { "name": "Neighboring Wifi networks" + }, + "plc_rx_rate": { + "name": "PLC downlink PHY rate" + }, + "plc_tx_rate": { + "name": "PLC uplink PHY rate" } }, "switch": { diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 8cf63cf07ae..9d8faab9b13 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( UpdateFirmwareCheck, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -117,14 +117,34 @@ PLCNET = LogicalNetwork( { "mac_address": "AA:BB:CC:DD:EE:FF", "attached_to_router": False, - } + "topology": LOCAL, + "user_device_name": "test1", + }, + { + "mac_address": "11:22:33:44:55:66", + "attached_to_router": True, + "topology": REMOTE, + "user_device_name": "test2", + }, + { + "mac_address": "12:34:56:78:9A:BC", + "attached_to_router": False, + "topology": REMOTE, + "user_device_name": "test3", + }, ], data_rates=[ { "mac_address_from": "AA:BB:CC:DD:EE:FF", "mac_address_to": "11:22:33:44:55:66", - "rx_rate": 0.0, - "tx_rate": 0.0, + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, }, ], ) @@ -136,5 +156,18 @@ PLCNET_ATTACHED = LogicalNetwork( "attached_to_router": True, } ], - data_rates=[], + data_rates=[ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, + }, + ], ) diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 88eb46d57e8..4ab4635683c 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -134,3 +134,99 @@ 'unit_of_measurement': None, }) # --- +# name: test_update_plc_phyrates + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- +# name: test_update_plc_phyrates.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 230457f5617..e6f02033425 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration +from .const import PLCNET from .mock import MockDevice from tests.common import async_fire_time_changed @@ -33,6 +34,30 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) await hass.config_entries.async_unload(entry.entry_id) @@ -100,3 +125,56 @@ async def test_sensor( assert state.state == "1" await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_plc_phyrates( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key_downlink = f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_uplink = f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + + # Emulate device failure + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.reset() + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].rx_rate) + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].tx_rate) + + await hass.config_entries.async_unload(entry.entry_id) From 446f560b5983f68437d7bee2eb4c56f1883e5bb9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:59:23 +0100 Subject: [PATCH 493/927] Set aiohttp shutdown_timeout on AppRunner instead of TCPSite (#105973) --- homeassistant/components/http/__init__.py | 4 +++- homeassistant/components/http/web_runner.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 449f00fb335..6bb0c154540 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -520,7 +520,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app, handler_cancellation=True) + self.runner = web.AppRunner( + self.app, handler_cancellation=True, shutdown_timeout=10 + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 6d03b874a64..5c0931b97ca 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -29,7 +29,6 @@ class HomeAssistantTCPSite(web.BaseSite): host: None | str | list[str], port: int, *, - shutdown_timeout: float = 10.0, ssl_context: SSLContext | None = None, backlog: int = 128, reuse_address: bool | None = None, @@ -38,7 +37,6 @@ class HomeAssistantTCPSite(web.BaseSite): """Initialize HomeAssistantTCPSite.""" super().__init__( runner, - shutdown_timeout=shutdown_timeout, ssl_context=ssl_context, backlog=backlog, ) From 29e30e796ad133d5582fefe8d3e23a1660218ecb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:59:30 +0100 Subject: [PATCH 494/927] Add significant change helper check_valid_float (#106005) --- homeassistant/helpers/significant_change.py | 9 +++++++++ tests/helpers/test_significant_change.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 589d792f1f8..9bda3ca4eb2 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -147,6 +147,15 @@ def check_percentage_change( return _check_numeric_change(old_state, new_state, change, percentage_change) +def check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + class SignificantlyChangedChecker: """Class to keep track of entities to see if they have significantly changed. diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index c0d9f1b3a4a..6444781aa85 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -72,3 +72,14 @@ async def test_significant_change_extra(hass: HomeAssistant, checker) -> None: State(ent_id, "200", attrs), extra_arg=1 ) assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2) + + +async def test_check_valid_float(hass: HomeAssistant) -> None: + """Test extra significant checker works.""" + assert significant_change.check_valid_float("1") + assert significant_change.check_valid_float("1.0") + assert significant_change.check_valid_float(1) + assert significant_change.check_valid_float(1.0) + assert not significant_change.check_valid_float("") + assert not significant_change.check_valid_float("invalid") + assert not significant_change.check_valid_float("1.1.1") From 17b53d7acbff4a3c46cc38d9f5136c460d60f923 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 19 Dec 2023 00:23:51 +0100 Subject: [PATCH 495/927] Remove leftover logging in onewire (#105986) Remove leftover logging --- homeassistant/components/onewire/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index cee3e64d29f..61cf3459c84 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -398,7 +398,6 @@ def get_entities( native_unit_of_measurement=PERCENTAGE, translation_key=f"wetness_{s_id}", ) - _LOGGER.info(description.translation_key) override_key = None if description.override_key: override_key = description.override_key(device_id, options) From d1f6b1271f521e6bd9c4ad054abfc2bdaae74363 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 19 Dec 2023 02:26:17 +0100 Subject: [PATCH 496/927] Avoid mutating entity descriptions in screenlogic (#106022) --- homeassistant/components/screenlogic/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 4cd14fb7a16..5cb4e5acfe9 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -272,7 +272,9 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) continue if gateway.get_data(*chem_sensor_data_path): - chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + chem_sensor_description = dataclasses.replace( + chem_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) scg_sensor_description: ScreenLogicSensorDescription @@ -285,7 +287,9 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) continue if gateway.get_data(*scg_sensor_data_path): - scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + scg_sensor_description = dataclasses.replace( + scg_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) async_add_entities(entities) From 649e8e689d4b5fde3a74c439bcce7837a2bd5e8a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 19 Dec 2023 02:26:38 +0100 Subject: [PATCH 497/927] Fix entity descriptions in upnp (#106023) --- homeassistant/components/upnp/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index e53d89018fb..add8039345b 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -19,7 +19,7 @@ class UpnpEntityDescription(EntityDescription): def __post_init__(self): """Post initialize.""" - self.value_key = self.value_key or self.key + object.__setattr__(self, "value_key", self.value_key or self.key) class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): From 6f392a3b430291fdd70fcd04be1481660bad4ee4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 19 Dec 2023 02:26:59 +0100 Subject: [PATCH 498/927] Avoid mutating entity descriptions in sunweg tests (#106024) --- tests/components/sunweg/test_init.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index ca191469377..0295e778f9c 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -91,9 +91,8 @@ async def test_sunwegdata_get_data_drop_threshold() -> None: data = SunWEGData(api, 123456) data.get_api_value = MagicMock() entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key" + api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 ) - entity_description.previous_value_drop_threshold = 0.1 data.get_api_value.return_value = 3.0 assert data.get_data( api_variable_key=entity_description.api_variable_key, @@ -138,9 +137,8 @@ async def test_sunwegdata_get_data_never_reset() -> None: data = SunWEGData(api, 123456) data.get_api_value = MagicMock() entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key" + api_variable_key="variable", key="key", never_resets=True ) - entity_description.never_resets = True data.get_api_value.return_value = 3.0 assert data.get_data( api_variable_key=entity_description.api_variable_key, From 953a035cb553e11a2f5335996d4dc8458198406c Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 19 Dec 2023 02:30:23 +0100 Subject: [PATCH 499/927] Update enigma2 codeowners (#106000) --- CODEOWNERS | 2 +- homeassistant/components/enigma2/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index c45293d03c8..c282e00049f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -346,7 +346,7 @@ build.json @home-assistant/supervisor /tests/components/energy/ @home-assistant/core /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas -/homeassistant/components/enigma2/ @fbradyirl +/homeassistant/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 932cbda66ec..a70d5a05eeb 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -1,7 +1,7 @@ { "domain": "enigma2", "name": "Enigma2 (OpenWebif)", - "codeowners": ["@fbradyirl"], + "codeowners": ["@autinerd"], "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], From 97e66ef9eed319bc1987f34d316dc14eadd8f27c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 18 Dec 2023 21:26:45 -0500 Subject: [PATCH 500/927] Bump Roborock to 0.38.0 (#106025) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index beb467d69f9..c149b9fcf7f 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.36.2", + "python-roborock==0.38.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index a9a5e9f8e7c..21ff3cf4a64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2218,7 +2218,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.36.2 +python-roborock==0.38.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39768ea7cbf..16fddd07090 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1666,7 +1666,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.36.2 +python-roborock==0.38.0 # homeassistant.components.smarttub python-smarttub==0.0.36 From ae8db120e8111c2db22d87ab8e0ead9ebfa12c67 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 19 Dec 2023 03:27:31 +0100 Subject: [PATCH 501/927] Upgrade gardena_bluetooth to 1.4.1 (#106017) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index bcbb25d55a2..6598aeaafd8 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena-bluetooth==1.4.0"] + "requirements": ["gardena-bluetooth==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21ff3cf4a64..5a17e29841a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -863,7 +863,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16fddd07090..43ae6db8d57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -686,7 +686,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 From 0c2485bc03cb42a2d08f7b3a4e3abf848510caa0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 19 Dec 2023 03:28:13 +0100 Subject: [PATCH 502/927] Freeze integration entity descriptions (#105984) Co-authored-by: J. Nick Koston --- homeassistant/components/abode/sensor.py | 4 +-- .../components/accuweather/sensor.py | 4 +-- homeassistant/components/adguard/sensor.py | 2 +- homeassistant/components/adguard/switch.py | 2 +- homeassistant/components/airly/sensor.py | 2 +- homeassistant/components/airnow/sensor.py | 4 +-- homeassistant/components/airq/sensor.py | 4 +-- .../components/airvisual_pro/sensor.py | 4 +-- .../components/airzone/binary_sensor.py | 2 +- homeassistant/components/airzone/select.py | 4 +-- .../components/airzone_cloud/binary_sensor.py | 2 +- .../components/aladdin_connect/sensor.py | 4 +-- .../ambient_station/binary_sensor.py | 4 +-- .../components/amcrest/binary_sensor.py | 2 +- .../components/android_ip_webcam/sensor.py | 4 +-- .../components/android_ip_webcam/switch.py | 4 +-- homeassistant/components/anova/sensor.py | 4 +-- homeassistant/components/aosmith/sensor.py | 2 +- homeassistant/components/aqualogic/sensor.py | 2 +- homeassistant/components/aranet/sensor.py | 2 +- .../aseko_pool_live/binary_sensor.py | 4 +-- homeassistant/components/asuswrt/sensor.py | 2 +- .../components/august/binary_sensor.py | 6 ++-- homeassistant/components/august/sensor.py | 4 +-- .../components/aussie_broadband/sensor.py | 2 +- homeassistant/components/awair/sensor.py | 4 +-- .../components/azure_devops/__init__.py | 2 +- .../components/azure_devops/sensor.py | 4 +-- homeassistant/components/baf/binary_sensor.py | 4 +-- homeassistant/components/baf/number.py | 4 +-- homeassistant/components/baf/sensor.py | 4 +-- homeassistant/components/baf/switch.py | 4 +-- .../components/balboa/binary_sensor.py | 4 +-- .../bmw_connected_drive/binary_sensor.py | 4 +-- .../components/bmw_connected_drive/button.py | 4 +-- .../components/bmw_connected_drive/number.py | 4 +-- .../components/bmw_connected_drive/select.py | 4 +-- .../components/bmw_connected_drive/sensor.py | 2 +- .../components/bmw_connected_drive/switch.py | 4 +-- homeassistant/components/bond/button.py | 4 +-- homeassistant/components/bosch_shc/switch.py | 4 +-- homeassistant/components/braviatv/button.py | 4 +-- homeassistant/components/brother/sensor.py | 4 +-- homeassistant/components/co2signal/sensor.py | 2 +- .../components/comfoconnect/sensor.py | 4 +-- homeassistant/components/daikin/sensor.py | 4 +-- .../components/deconz/binary_sensor.py | 2 +- homeassistant/components/deconz/button.py | 2 +- homeassistant/components/deconz/number.py | 2 +- homeassistant/components/deconz/sensor.py | 2 +- homeassistant/components/deluge/sensor.py | 2 +- .../devolo_home_network/binary_sensor.py | 4 +-- .../components/devolo_home_network/button.py | 4 +-- .../components/devolo_home_network/image.py | 4 +-- .../components/devolo_home_network/sensor.py | 4 +-- .../components/devolo_home_network/switch.py | 4 +-- .../components/devolo_home_network/update.py | 4 +-- homeassistant/components/discovergy/sensor.py | 2 +- homeassistant/components/doorbird/button.py | 4 +-- .../dormakaba_dkey/binary_sensor.py | 4 +-- homeassistant/components/dovado/sensor.py | 4 +-- .../dremel_3d_printer/binary_sensor.py | 4 +-- .../components/dremel_3d_printer/button.py | 4 +-- .../components/dremel_3d_printer/sensor.py | 4 +-- homeassistant/components/dsmr/sensor.py | 2 +- .../components/dsmr_reader/definitions.py | 2 +- homeassistant/components/easyenergy/sensor.py | 4 +-- homeassistant/components/ecobee/number.py | 4 +-- homeassistant/components/ecobee/sensor.py | 4 +-- homeassistant/components/ecoforest/number.py | 4 +-- homeassistant/components/ecoforest/sensor.py | 4 +-- homeassistant/components/ecoforest/switch.py | 4 +-- .../components/electric_kiwi/sensor.py | 4 +-- homeassistant/components/elgato/button.py | 2 +- homeassistant/components/elgato/sensor.py | 2 +- homeassistant/components/elgato/switch.py | 2 +- homeassistant/components/energyzero/sensor.py | 4 +-- homeassistant/components/enocean/sensor.py | 4 +-- .../components/enphase_envoy/binary_sensor.py | 8 +++--- .../components/enphase_envoy/number.py | 8 +++--- .../components/enphase_envoy/select.py | 8 +++--- .../components/enphase_envoy/sensor.py | 28 +++++++++---------- .../components/enphase_envoy/switch.py | 12 ++++---- .../components/environment_canada/sensor.py | 4 +-- .../components/ezviz/alarm_control_panel.py | 4 +-- homeassistant/components/ezviz/button.py | 4 +-- homeassistant/components/ezviz/number.py | 4 +-- homeassistant/components/ezviz/select.py | 4 +-- homeassistant/components/ezviz/switch.py | 4 +-- .../components/faa_delays/binary_sensor.py | 2 +- homeassistant/components/fitbit/sensor.py | 2 +- .../components/fivem/binary_sensor.py | 2 +- homeassistant/components/fivem/entity.py | 2 +- homeassistant/components/fivem/sensor.py | 2 +- .../components/fjaraskupan/binary_sensor.py | 2 +- .../components/flume/binary_sensor.py | 4 +-- .../components/forecast_solar/sensor.py | 2 +- homeassistant/components/freebox/button.py | 4 +-- .../components/fritz/binary_sensor.py | 2 +- homeassistant/components/fritz/button.py | 4 +-- homeassistant/components/fritz/common.py | 4 +-- homeassistant/components/fritz/sensor.py | 2 +- homeassistant/components/fritz/update.py | 2 +- .../components/fritzbox/binary_sensor.py | 4 +-- homeassistant/components/fritzbox/model.py | 2 +- homeassistant/components/fritzbox/sensor.py | 4 +-- homeassistant/components/fronius/sensor.py | 2 +- .../components/fully_kiosk/button.py | 4 +-- .../components/fully_kiosk/sensor.py | 2 +- .../components/fully_kiosk/switch.py | 4 +-- .../gardena_bluetooth/binary_sensor.py | 2 +- .../components/gardena_bluetooth/button.py | 2 +- .../components/gardena_bluetooth/number.py | 2 +- .../components/gardena_bluetooth/sensor.py | 2 +- homeassistant/components/geocaching/sensor.py | 4 +-- homeassistant/components/gios/sensor.py | 4 +-- homeassistant/components/github/sensor.py | 6 ++-- homeassistant/components/glances/sensor.py | 4 +-- homeassistant/components/goodwe/button.py | 4 +-- homeassistant/components/goodwe/number.py | 4 +-- homeassistant/components/goodwe/sensor.py | 2 +- .../components/google_wifi/sensor.py | 4 +-- homeassistant/components/gree/switch.py | 4 +-- .../sensor_types/sensor_entity_description.py | 4 +-- homeassistant/components/guardian/__init__.py | 4 +-- .../components/guardian/binary_sensor.py | 2 +- homeassistant/components/guardian/button.py | 4 +-- homeassistant/components/guardian/sensor.py | 2 +- homeassistant/components/guardian/switch.py | 4 +-- .../components/hassio/binary_sensor.py | 2 +- .../components/homekit_controller/button.py | 2 +- .../components/homekit_controller/select.py | 4 +-- .../components/homekit_controller/sensor.py | 2 +- .../components/homekit_controller/switch.py | 2 +- homeassistant/components/homewizard/sensor.py | 2 +- homeassistant/components/homewizard/switch.py | 2 +- homeassistant/components/honeywell/sensor.py | 4 +-- homeassistant/components/huawei_lte/select.py | 4 +-- homeassistant/components/huawei_lte/sensor.py | 2 +- homeassistant/components/huisbaasje/sensor.py | 2 +- .../hunterdouglas_powerview/button.py | 4 +-- .../hunterdouglas_powerview/select.py | 4 +-- .../hunterdouglas_powerview/sensor.py | 4 +-- homeassistant/components/iammeter/sensor.py | 2 +- homeassistant/components/ibeacon/sensor.py | 4 +-- .../components/idasen_desk/button.py | 4 +-- .../components/idasen_desk/sensor.py | 4 +-- homeassistant/components/incomfort/sensor.py | 2 +- .../components/intellifire/binary_sensor.py | 4 +-- homeassistant/components/intellifire/fan.py | 4 +-- homeassistant/components/intellifire/light.py | 4 +-- .../components/intellifire/sensor.py | 4 +-- .../components/intellifire/switch.py | 4 +-- homeassistant/components/iotawatt/sensor.py | 2 +- homeassistant/components/ipma/sensor.py | 4 +-- homeassistant/components/ipp/sensor.py | 4 +-- homeassistant/components/isy994/switch.py | 2 +- homeassistant/components/jellyfin/sensor.py | 4 +-- .../jewish_calendar/binary_sensor.py | 4 +-- homeassistant/components/juicenet/number.py | 4 +-- homeassistant/components/justnimbus/sensor.py | 4 +-- homeassistant/components/kaiterra/sensor.py | 4 +-- .../components/kaleidescape/sensor.py | 4 +-- homeassistant/components/knx/sensor.py | 2 +- .../components/kostal_plenticore/number.py | 4 +-- .../components/kostal_plenticore/select.py | 4 +-- .../components/kostal_plenticore/sensor.py | 4 +-- .../components/kostal_plenticore/switch.py | 4 +-- homeassistant/components/kraken/sensor.py | 4 +-- .../components/lacrosse_view/sensor.py | 4 +-- homeassistant/components/lametric/button.py | 2 +- homeassistant/components/lametric/number.py | 2 +- homeassistant/components/lametric/select.py | 2 +- homeassistant/components/lametric/sensor.py | 2 +- homeassistant/components/lametric/switch.py | 2 +- .../components/landisgyr_heat_meter/sensor.py | 4 +-- .../components/launch_library/sensor.py | 4 +-- homeassistant/components/lidarr/sensor.py | 4 +-- .../components/litterrobot/binary_sensor.py | 4 +-- .../components/litterrobot/button.py | 4 +-- .../components/litterrobot/select.py | 4 +-- .../components/litterrobot/sensor.py | 2 +- .../components/litterrobot/switch.py | 4 +-- homeassistant/components/litterrobot/time.py | 4 +-- homeassistant/components/lyric/sensor.py | 4 +-- .../components/matter/binary_sensor.py | 2 +- homeassistant/components/matter/entity.py | 2 +- homeassistant/components/matter/sensor.py | 2 +- homeassistant/components/meater/sensor.py | 4 +-- homeassistant/components/melcloud/sensor.py | 4 +-- homeassistant/components/melnor/number.py | 4 +-- homeassistant/components/melnor/sensor.py | 8 +++--- homeassistant/components/melnor/switch.py | 4 +-- homeassistant/components/melnor/time.py | 4 +-- .../components/meteo_france/sensor.py | 4 +-- .../minecraft_server/binary_sensor.py | 2 +- .../components/minecraft_server/sensor.py | 4 +-- .../components/mysensors/binary_sensor.py | 2 +- homeassistant/components/mystrom/sensor.py | 2 +- homeassistant/components/nam/sensor.py | 4 +-- homeassistant/components/netatmo/sensor.py | 4 +-- homeassistant/components/netgear/button.py | 4 +-- homeassistant/components/netgear/sensor.py | 2 +- homeassistant/components/netgear/switch.py | 4 +-- homeassistant/components/nextcloud/sensor.py | 2 +- .../components/nextdns/binary_sensor.py | 4 +-- homeassistant/components/nextdns/sensor.py | 4 +-- homeassistant/components/nextdns/switch.py | 4 +-- .../components/notion/binary_sensor.py | 4 +-- homeassistant/components/notion/model.py | 2 +- homeassistant/components/notion/sensor.py | 2 +- homeassistant/components/nws/sensor.py | 2 +- .../components/onewire/binary_sensor.py | 2 +- .../components/onewire/onewire_entities.py | 2 +- homeassistant/components/onewire/sensor.py | 2 +- homeassistant/components/onewire/switch.py | 2 +- homeassistant/components/onvif/switch.py | 4 +-- homeassistant/components/openuv/sensor.py | 4 +-- homeassistant/components/opower/sensor.py | 4 +-- .../components/overkiz/alarm_control_panel.py | 4 +-- .../components/overkiz/binary_sensor.py | 4 +-- homeassistant/components/overkiz/button.py | 2 +- homeassistant/components/overkiz/number.py | 4 +-- homeassistant/components/overkiz/select.py | 4 +-- homeassistant/components/overkiz/sensor.py | 2 +- homeassistant/components/overkiz/switch.py | 4 +-- homeassistant/components/ovo_energy/sensor.py | 2 +- homeassistant/components/peco/sensor.py | 4 +-- .../components/pegel_online/sensor.py | 4 +-- homeassistant/components/permobil/sensor.py | 4 +-- .../components/philips_js/binary_sensor.py | 2 +- .../components/pi_hole/binary_sensor.py | 4 +-- homeassistant/components/pi_hole/update.py | 2 +- homeassistant/components/picnic/sensor.py | 4 +-- .../components/plugwise/binary_sensor.py | 2 +- homeassistant/components/plugwise/number.py | 2 +- homeassistant/components/plugwise/select.py | 2 +- homeassistant/components/plugwise/sensor.py | 2 +- homeassistant/components/plugwise/switch.py | 2 +- homeassistant/components/point/sensor.py | 4 +-- homeassistant/components/powerwall/sensor.py | 4 +-- .../components/private_ble_device/sensor.py | 4 +-- homeassistant/components/prusalink/button.py | 4 +-- homeassistant/components/prusalink/sensor.py | 4 +-- .../components/pure_energie/sensor.py | 4 +-- homeassistant/components/purpleair/sensor.py | 4 +-- homeassistant/components/pvoutput/sensor.py | 2 +- .../components/qbittorrent/sensor.py | 4 +-- .../components/qnap_qsw/binary_sensor.py | 2 +- homeassistant/components/qnap_qsw/button.py | 4 +-- homeassistant/components/qnap_qsw/entity.py | 3 +- homeassistant/components/qnap_qsw/sensor.py | 2 +- homeassistant/components/radarr/sensor.py | 4 +-- .../components/rainmachine/binary_sensor.py | 2 +- .../components/rainmachine/button.py | 4 +-- homeassistant/components/rainmachine/model.py | 8 +++--- .../components/rainmachine/select.py | 6 ++-- .../components/rainmachine/sensor.py | 4 +-- .../components/rainmachine/switch.py | 6 ++-- homeassistant/components/rdw/binary_sensor.py | 2 +- homeassistant/components/rdw/sensor.py | 2 +- .../components/renault/binary_sensor.py | 4 +-- homeassistant/components/renault/button.py | 4 +-- homeassistant/components/renault/entity.py | 4 +-- homeassistant/components/renault/select.py | 4 +-- homeassistant/components/renault/sensor.py | 4 +-- .../components/renson/binary_sensor.py | 4 +-- homeassistant/components/renson/button.py | 4 +-- homeassistant/components/renson/sensor.py | 4 +-- .../components/reolink/binary_sensor.py | 2 +- homeassistant/components/reolink/button.py | 4 +-- homeassistant/components/reolink/camera.py | 2 +- homeassistant/components/reolink/entity.py | 4 +-- homeassistant/components/reolink/light.py | 2 +- homeassistant/components/reolink/number.py | 2 +- homeassistant/components/reolink/select.py | 2 +- homeassistant/components/reolink/sensor.py | 4 +-- homeassistant/components/reolink/siren.py | 2 +- homeassistant/components/reolink/switch.py | 4 +-- homeassistant/components/repetier/__init__.py | 4 +-- homeassistant/components/rfxtrx/sensor.py | 2 +- .../components/ring/binary_sensor.py | 4 +-- homeassistant/components/ring/sensor.py | 4 +-- .../rituals_perfume_genie/binary_sensor.py | 2 +- .../rituals_perfume_genie/number.py | 2 +- .../rituals_perfume_genie/select.py | 2 +- .../rituals_perfume_genie/sensor.py | 2 +- .../rituals_perfume_genie/switch.py | 4 +-- .../components/roborock/binary_sensor.py | 4 +-- homeassistant/components/roborock/button.py | 4 +-- homeassistant/components/roborock/number.py | 4 +-- homeassistant/components/roborock/select.py | 4 +-- homeassistant/components/roborock/sensor.py | 4 +-- homeassistant/components/roborock/switch.py | 4 +-- homeassistant/components/roborock/time.py | 4 +-- .../components/roku/binary_sensor.py | 4 +-- homeassistant/components/roku/select.py | 4 +-- homeassistant/components/roku/sensor.py | 4 +-- homeassistant/components/roomba/sensor.py | 4 +-- homeassistant/components/sabnzbd/sensor.py | 4 +-- .../components/schlage/binary_sensor.py | 4 +-- homeassistant/components/schlage/switch.py | 4 +-- .../components/screenlogic/binary_sensor.py | 4 +-- .../components/screenlogic/climate.py | 2 +- .../components/screenlogic/entity.py | 8 +++--- homeassistant/components/screenlogic/light.py | 2 +- .../components/screenlogic/number.py | 4 +-- .../components/screenlogic/sensor.py | 6 ++-- .../components/screenlogic/switch.py | 2 +- .../components/sensibo/binary_sensor.py | 8 +++--- homeassistant/components/sensibo/button.py | 4 +-- homeassistant/components/sensibo/number.py | 4 +-- homeassistant/components/sensibo/select.py | 4 +-- homeassistant/components/sensibo/sensor.py | 8 +++--- homeassistant/components/sensibo/switch.py | 4 +-- homeassistant/components/sensibo/update.py | 4 +-- .../components/sfr_box/binary_sensor.py | 4 +-- homeassistant/components/sfr_box/button.py | 4 +-- homeassistant/components/sfr_box/sensor.py | 4 +-- .../components/shelly/binary_sensor.py | 6 ++-- homeassistant/components/shelly/button.py | 4 +-- homeassistant/components/shelly/entity.py | 8 +++--- homeassistant/components/shelly/event.py | 4 +-- homeassistant/components/shelly/number.py | 2 +- homeassistant/components/shelly/sensor.py | 6 ++-- homeassistant/components/shelly/switch.py | 2 +- homeassistant/components/shelly/update.py | 8 +++--- .../components/sia/alarm_control_panel.py | 2 +- homeassistant/components/sia/binary_sensor.py | 2 +- .../components/sia/sia_entity_base.py | 4 +-- homeassistant/components/simplisafe/button.py | 4 +-- homeassistant/components/skybell/sensor.py | 4 +-- homeassistant/components/sleepiq/button.py | 4 +-- homeassistant/components/sleepiq/number.py | 4 +-- homeassistant/components/smappee/sensor.py | 8 +++--- homeassistant/components/solaredge/sensor.py | 4 +-- .../components/solaredge_local/sensor.py | 2 +- homeassistant/components/solarlog/sensor.py | 2 +- homeassistant/components/sonarr/sensor.py | 4 +-- .../components/speedtestdotnet/sensor.py | 2 +- homeassistant/components/starline/switch.py | 4 +-- .../components/starlink/binary_sensor.py | 4 +-- homeassistant/components/starlink/button.py | 4 +-- .../components/starlink/device_tracker.py | 4 +-- homeassistant/components/starlink/sensor.py | 4 +-- homeassistant/components/starlink/switch.py | 4 +-- homeassistant/components/steamist/sensor.py | 4 +-- homeassistant/components/sun/sensor.py | 4 +-- .../sensor_types/sensor_entity_description.py | 4 +-- .../components/switcher_kis/button.py | 4 +-- .../components/synology_dsm/binary_sensor.py | 2 +- .../components/synology_dsm/button.py | 4 +-- .../components/synology_dsm/camera.py | 2 +- .../components/synology_dsm/entity.py | 4 +-- .../components/synology_dsm/sensor.py | 2 +- .../components/synology_dsm/switch.py | 2 +- .../components/synology_dsm/update.py | 2 +- .../components/system_bridge/binary_sensor.py | 2 +- .../components/system_bridge/sensor.py | 2 +- .../components/systemmonitor/sensor.py | 2 +- .../components/tado/binary_sensor.py | 4 +-- homeassistant/components/tado/sensor.py | 4 +-- .../components/tailscale/binary_sensor.py | 2 +- homeassistant/components/tailscale/sensor.py | 2 +- homeassistant/components/tailwind/button.py | 2 +- homeassistant/components/tailwind/number.py | 2 +- homeassistant/components/tautulli/sensor.py | 8 +++--- .../tesla_wall_connector/__init__.py | 2 +- .../tesla_wall_connector/binary_sensor.py | 2 +- .../components/tesla_wall_connector/sensor.py | 2 +- .../components/tessie/binary_sensor.py | 2 +- homeassistant/components/tessie/sensor.py | 2 +- homeassistant/components/tolo/number.py | 4 +-- homeassistant/components/tolo/sensor.py | 4 +-- homeassistant/components/tomorrowio/sensor.py | 2 +- .../components/toon/binary_sensor.py | 4 +-- homeassistant/components/toon/models.py | 2 +- homeassistant/components/toon/sensor.py | 4 +-- homeassistant/components/toon/switch.py | 4 +-- homeassistant/components/tplink/sensor.py | 2 +- homeassistant/components/tractive/sensor.py | 4 +-- homeassistant/components/tractive/switch.py | 4 +-- homeassistant/components/tradfri/sensor.py | 4 +-- .../trafikverket_camera/binary_sensor.py | 4 +-- .../components/trafikverket_camera/sensor.py | 4 +-- .../components/trafikverket_ferry/sensor.py | 4 +-- .../components/trafikverket_train/sensor.py | 4 +-- .../trafikverket_weatherstation/sensor.py | 4 +-- .../components/transmission/sensor.py | 2 +- .../components/transmission/switch.py | 4 +-- .../components/tuya/binary_sensor.py | 2 +- homeassistant/components/tuya/climate.py | 4 +-- homeassistant/components/tuya/cover.py | 2 +- homeassistant/components/tuya/humidifier.py | 2 +- homeassistant/components/tuya/light.py | 2 +- homeassistant/components/tuya/sensor.py | 2 +- .../components/twentemilieu/sensor.py | 2 +- homeassistant/components/unifi/button.py | 4 +-- .../components/unifi/device_tracker.py | 4 +-- homeassistant/components/unifi/entity.py | 4 +-- homeassistant/components/unifi/image.py | 4 +-- homeassistant/components/unifi/sensor.py | 4 +-- homeassistant/components/unifi/switch.py | 4 +-- homeassistant/components/unifi/update.py | 4 +-- .../components/unifiprotect/binary_sensor.py | 4 +-- .../components/unifiprotect/button.py | 2 +- .../components/unifiprotect/models.py | 6 ++-- .../components/unifiprotect/number.py | 4 +-- .../components/unifiprotect/select.py | 2 +- .../components/unifiprotect/sensor.py | 4 +-- .../components/unifiprotect/switch.py | 2 +- homeassistant/components/unifiprotect/text.py | 2 +- .../components/upnp/binary_sensor.py | 2 +- homeassistant/components/upnp/entity.py | 2 +- homeassistant/components/upnp/sensor.py | 2 +- homeassistant/components/v2c/binary_sensor.py | 4 +-- homeassistant/components/v2c/number.py | 4 +-- homeassistant/components/v2c/sensor.py | 4 +-- homeassistant/components/v2c/switch.py | 4 +-- .../components/vallox/binary_sensor.py | 4 +-- homeassistant/components/vallox/number.py | 4 +-- homeassistant/components/vallox/sensor.py | 2 +- homeassistant/components/vallox/switch.py | 4 +-- homeassistant/components/venstar/sensor.py | 4 +-- homeassistant/components/vesync/sensor.py | 4 +-- homeassistant/components/vicare/__init__.py | 4 +-- .../components/vicare/binary_sensor.py | 2 +- homeassistant/components/vicare/button.py | 2 +- homeassistant/components/vicare/number.py | 2 +- homeassistant/components/vicare/sensor.py | 2 +- homeassistant/components/vilfo/sensor.py | 4 +-- .../components/vodafone_station/button.py | 4 +-- .../components/vodafone_station/sensor.py | 4 +-- homeassistant/components/wallbox/number.py | 4 +-- homeassistant/components/wallbox/sensor.py | 2 +- homeassistant/components/waqi/sensor.py | 4 +-- .../components/weatherflow/sensor.py | 4 +-- homeassistant/components/wemo/sensor.py | 2 +- homeassistant/components/whirlpool/sensor.py | 4 +-- homeassistant/components/whois/sensor.py | 2 +- homeassistant/components/withings/sensor.py | 10 +++---- homeassistant/components/wiz/number.py | 2 +- homeassistant/components/wled/number.py | 2 +- homeassistant/components/wled/sensor.py | 2 +- .../components/xiaomi_miio/binary_sensor.py | 2 +- .../components/xiaomi_miio/button.py | 2 +- .../components/xiaomi_miio/number.py | 4 +-- .../components/xiaomi_miio/select.py | 2 +- .../components/xiaomi_miio/sensor.py | 2 +- .../components/xiaomi_miio/switch.py | 4 +-- homeassistant/components/yalexs_ble/sensor.py | 4 +-- .../components/yolink/binary_sensor.py | 2 +- homeassistant/components/yolink/sensor.py | 4 +-- homeassistant/components/yolink/siren.py | 2 +- homeassistant/components/yolink/switch.py | 2 +- homeassistant/components/youtube/sensor.py | 4 +-- homeassistant/components/zamg/sensor.py | 4 +-- homeassistant/components/zeversolar/sensor.py | 4 +-- .../components/zwave_js/binary_sensor.py | 6 ++-- .../components/zwave_js/humidifier.py | 4 +-- homeassistant/components/zwave_me/sensor.py | 2 +- homeassistant/util/frozen_dataclass_compat.py | 1 + 462 files changed, 806 insertions(+), 804 deletions(-) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index bceed215428..1b1dbe8b30a 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -27,7 +27,7 @@ ABODE_TEMPERATURE_UNIT_HA_UNIT = { } -@dataclass +@dataclass(frozen=True) class AbodeSensorDescriptionMixin: """Mixin for Abode sensor.""" @@ -35,7 +35,7 @@ class AbodeSensorDescriptionMixin: native_unit_of_measurement_fn: Callable[[AbodeSense], str] -@dataclass +@dataclass(frozen=True) class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin): """Class describing Abode sensor entities.""" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index c983f0bc291..2219c5de4b6 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -45,14 +45,14 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" value_fn: Callable[[dict[str, Any]], str | int | float | None] -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescription( SensorEntityDescription, AccuWeatherSensorDescriptionMixin ): diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 523e1b73e16..c8ec5023533 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AdGuardHomeEntityDescription(SensorEntityDescription): """Describes AdGuard Home sensor entity.""" diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 944a3c7b269..4b6fe06cdab 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -21,7 +21,7 @@ SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AdGuardHomeSwitchEntityDescription(SwitchEntityDescription): """Describes AdGuard Home switch entity.""" diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 864c36f171a..6105b277088 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -56,7 +56,7 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AirlySensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c6ab27a8497..9c154dc0712 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -51,7 +51,7 @@ ATTR_LEVEL = "level" ATTR_STATION = "reporting_station" -@dataclass +@dataclass(frozen=True) class AirNowEntityDescriptionMixin: """Mixin for required keys.""" @@ -59,7 +59,7 @@ class AirNowEntityDescriptionMixin: extra_state_attributes_fn: Callable[[Any], dict[str, str]] | None -@dataclass +@dataclass(frozen=True) class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMixin): """Describes Airnow sensor entity.""" diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 9974307b4cd..f1fdfb289dd 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -37,14 +37,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class AirQEntityDescriptionMixin: """Class for keys required by AirQ entity.""" value: Callable[[dict], float | int | None] -@dataclass +@dataclass(frozen=True) class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin): """Describes AirQ sensor entity.""" diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 188647b7338..6a8e32bc32c 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -26,7 +26,7 @@ from . import AirVisualProData, AirVisualProEntity from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementKeyMixin: """Define an entity description mixin to include a measurement key.""" @@ -35,7 +35,7 @@ class AirVisualProMeasurementKeyMixin: ] -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementDescription( SensorEntityDescription, AirVisualProMeasurementKeyMixin ): diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index cee0bb19691..488c2c96132 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -29,7 +29,7 @@ from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes airzone binary sensor entities.""" diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 78b4dee3b72..6f69d4454ee 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -26,7 +26,7 @@ from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -34,7 +34,7 @@ class AirzoneSelectDescriptionMixin: options_dict: dict[str, int] -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescriptionMixin): """Class to describe an Airzone select entity.""" diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 2a182b7b487..9f99e49f650 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -34,7 +34,7 @@ from .entity import ( ) -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes Airzone Cloud binary sensor entities.""" diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index e3a1f2d443c..0a264edc8c2 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -23,14 +23,14 @@ from .const import DOMAIN from .model import DoorDevice -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescription( SensorEntityDescription, AccSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 49ff43bcc7e..8bdfe0fd642 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -63,14 +63,14 @@ TYPE_RELAY8 = "relay8" TYPE_RELAY9 = "relay9" -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensors.""" on_state: Literal[0, 1] -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescription( BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index e71a5cda538..a0b6b4f6527 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: from . import AmcrestDevice -@dataclass +@dataclass(frozen=True) class AmcrestSensorEntityDescription(BinarySensorEntityDescription): """Describe Amcrest sensor entity.""" diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index 7a08d774f6a..d7a821d956a 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -23,14 +23,14 @@ from .coordinator import AndroidIPCamDataUpdateCoordinator from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PyDroidIPCam], StateType] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescription( SensorEntityDescription, AndroidIPWebcamSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index 1eca19fe395..bae84739079 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -18,7 +18,7 @@ from .coordinator import AndroidIPCamDataUpdateCoordinator from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class AndroidIPWebcamSwitchEntityDescriptionMixin: off_func: Callable[[PyDroidIPCam], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescription( SwitchEntityDescription, AndroidIPWebcamSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 6336aa61e1c..b7657e26249 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -23,14 +23,14 @@ from .entity import AnovaDescriptionEntity from .models import AnovaData -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescriptionMixin: """Describes the mixin variables for anova sensors.""" value_fn: Callable[[APCUpdateSensor], float | int | str] -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescription( SensorEntityDescription, AnovaSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index c9bd9f1321e..78c6f32232a 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -19,7 +19,7 @@ from .coordinator import AOSmithCoordinator from .entity import AOSmithEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AOSmithSensorEntityDescription(SensorEntityDescription): """Define sensor entity description class.""" diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 955293a938e..90f87bfde23 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor -@dataclass +@dataclass(frozen=True) class AquaLogicSensorEntityDescription(SensorEntityDescription): """Describes AquaLogic sensor entity.""" diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index ad11b4bdbdc..23d3b64fdca 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AranetSensorEntityDescription(SensorEntityDescription): """Class to describe an Aranet sensor entity.""" diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 3e0e57fffac..cc91b6b97a6 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -20,14 +20,14 @@ from .coordinator import AsekoDataUpdateCoordinator from .entity import AsekoEntity -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Unit], bool] -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorEntityDescription( BinarySensorEntityDescription, AsekoBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 4f9ec0af411..72d99c60816 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -38,7 +38,7 @@ from .const import ( from .router import AsusWrtRouter -@dataclass +@dataclass(frozen=True) class AsusWrtSensorEntityDescription(SensorEntityDescription): """A class that describes AsusWrt sensor entities.""" diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index b19a9833a47..144666844e7 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -105,12 +105,12 @@ def _native_datetime() -> datetime: return datetime.now() -@dataclass +@dataclass(frozen=True) class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes August binary_sensor entity.""" -@dataclass +@dataclass(frozen=True) class AugustDoorbellRequiredKeysMixin: """Mixin for required keys.""" @@ -118,7 +118,7 @@ class AugustDoorbellRequiredKeysMixin: is_time_based: bool -@dataclass +@dataclass(frozen=True) class AugustDoorbellBinarySensorEntityDescription( BinarySensorEntityDescription, AugustDoorbellRequiredKeysMixin ): diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 7f6e0c51995..1896a91c54f 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -63,14 +63,14 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: _T = TypeVar("_T", LockDetail, KeypadDetail) -@dataclass +@dataclass(frozen=True) class AugustRequiredKeysMixin(Generic[_T]): """Mixin for required keys.""" value_fn: Callable[[_T], int | None] -@dataclass +@dataclass(frozen=True) class AugustSensorEntityDescription( SensorEntityDescription, AugustRequiredKeysMixin[_T] ): diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index aff232f2934..efc8ae99ef9 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_ID -@dataclass +@dataclass(frozen=True) class SensorValueEntityDescription(SensorEntityDescription): """Class describing Aussie Broadband sensor entities.""" diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 2a09a8d4e70..698850d6a49 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -50,14 +50,14 @@ from .coordinator import AwairDataUpdateCoordinator, AwairResult DUST_ALIASES = [API_PM25, API_PM10] -@dataclass +@dataclass(frozen=True) class AwairRequiredKeysMixin: """Mixin for required keys.""" unique_id_tag: str -@dataclass +@dataclass(frozen=True) class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): """Describes Awair sensor entity.""" diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index dc3d0e5b04b..edd06d69d2e 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -32,7 +32,7 @@ PLATFORMS = [Platform.SENSOR] BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" -@dataclass +@dataclass(frozen=True) class AzureDevOpsEntityDescription(EntityDescription): """Class describing Azure DevOps entities.""" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index ac884f73d68..6daf9b434df 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -17,14 +17,14 @@ from . import AzureDevOpsDeviceEntity, AzureDevOpsEntityDescription from .const import CONF_ORG, DOMAIN -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescriptionMixin: """Mixin class for required Azure DevOps sensor description keys.""" build_key: int -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescription( AzureDevOpsEntityDescription, SensorEntityDescription, diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index a68e80c3ac2..50e8cd78629 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -21,14 +21,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescriptionMixin: """Required values for BAF binary sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescription( BinarySensorEntityDescription, BAFBinarySensorDescriptionMixin, diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 7fd1c9ed290..9dd4180c7e1 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -22,14 +22,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] -@dataclass +@dataclass(frozen=True) class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): """Class describing BAF sensor entities.""" diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index d8111804142..5c8d8f2979b 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -28,14 +28,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSensorDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | float | str | None] -@dataclass +@dataclass(frozen=True) class BAFSensorDescription( SensorEntityDescription, BAFSensorDescriptionMixin, diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index ed4e635ece3..ccb8aee36e5 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -18,14 +18,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSwitchDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFSwitchDescription( SwitchEntityDescription, BAFSwitchDescriptionMixin, diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 7462d051643..ec7a9fe484a 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -41,7 +41,7 @@ class BalboaBinarySensorEntityDescriptionMixin: on_off_icons: tuple[str, str] -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescription( BinarySensorEntityDescription, BalboaBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 0e3750de085..29c4d61e9f7 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -109,14 +109,14 @@ def _format_cbs_report( return result -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[MyBMWVehicle], bool] -@dataclass +@dataclass(frozen=True) class BMWBinarySensorEntityDescription( BinarySensorEntityDescription, BMWRequiredKeysMixin ): diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index c3f066610a9..f2a123fe4a8 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -25,14 +25,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] -@dataclass +@dataclass(frozen=True) class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): """Class describing BMW button entities.""" diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index f37f7627140..0ed732e1dcb 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -26,7 +26,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): """Describes BMW number entity.""" diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 1d8b736f4dd..8823c6552cc 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -22,7 +22,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): """Describes BMW sensor entity.""" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 62854badb20..d486c41ae56 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -28,7 +28,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 298338dc9fa..e4ce0ba81ff 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -22,7 +22,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class BMWRequiredKeysMixin: remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): """Describes BMW switch entity.""" diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 1109cf0d311..273ef837f6e 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -21,7 +21,7 @@ from .utils import BondDevice, BondHub STEP_SIZE = 10 -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescriptionMixin: """Mixin to describe a Bond Button entity.""" @@ -29,7 +29,7 @@ class BondButtonEntityDescriptionMixin: argument: int | None -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescription( ButtonEntityDescription, BondButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 25af0628780..03d3ba2f6a9 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -29,7 +29,7 @@ from .const import DATA_SESSION, DOMAIN from .entity import SHCEntity -@dataclass +@dataclass(frozen=True) class SHCSwitchRequiredKeysMixin: """Mixin for SHC switch required keys.""" @@ -38,7 +38,7 @@ class SHCSwitchRequiredKeysMixin: should_poll: bool -@dataclass +@dataclass(frozen=True) class SHCSwitchEntityDescription( SwitchEntityDescription, SHCSwitchRequiredKeysMixin, diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 1f6c9961c51..eb3d2d8797f 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -19,14 +19,14 @@ from .coordinator import BraviaTVCoordinator from .entity import BraviaTVEntity -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescriptionMixin: """Mixin to describe a Bravia TV Button entity.""" press_action: Callable[[BraviaTVCoordinator], Coroutine] -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescription( ButtonEntityDescription, BraviaTVButtonDescriptionMixin ): diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index e9554d84207..27e4b7fd715 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -35,14 +35,14 @@ UNIT_PAGES = "p" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BrotherSensorRequiredKeysMixin: """Class for Brother entity required keys.""" value: Callable[[BrotherSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class BrotherSensorEntityDescription( SensorEntityDescription, BrotherSensorRequiredKeysMixin ): diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 00051d8bec9..9f955e35ed8 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -22,7 +22,7 @@ from .const import ATTRIBUTION, DOMAIN from .coordinator import CO2SignalCoordinator -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class CO2SensorEntityDescription(SensorEntityDescription): """Provide a description of a CO2 sensor.""" diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 21e6eda255d..421643f5ced 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -79,14 +79,14 @@ ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ComfoconnectRequiredKeysMixin: """Mixin for required keys.""" sensor_id: int -@dataclass +@dataclass(frozen=True) class ComfoconnectSensorEntityDescription( SensorEntityDescription, ComfoconnectRequiredKeysMixin ): diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 1646e292ee9..9e7a181ba32 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -39,14 +39,14 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class DaikinRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[Appliance], float | None] -@dataclass +@dataclass(frozen=True) class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): """Describes Daikin sensor entity.""" diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 84141eac964..c0a4e2585a3 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -65,7 +65,7 @@ T = TypeVar( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzBinarySensorDescription(Generic[T], BinarySensorEntityDescription): """Class describing deCONZ binary sensor entities.""" diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 81d839ea0f2..52105c10203 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -23,7 +23,7 @@ from .deconz_device import DeconzDevice, DeconzSceneMixin from .gateway import DeconzGateway, get_gateway_from_config_entry -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzButtonDescription(ButtonEntityDescription): """Class describing deCONZ button entities.""" diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 7cc0da936cb..e98f5d726ac 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -31,7 +31,7 @@ from .util import serial_from_unique_id T = TypeVar("T", Presence, PydeconzSensorBase) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzNumberDescription(Generic[T], NumberEntityDescription): """Class describing deCONZ number entities.""" diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index ecb9ac9b297..8366c811318 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -93,7 +93,7 @@ T = TypeVar( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzSensorDescription(Generic[T], SensorEntityDescription): """Class describing deCONZ binary sensor entities.""" diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 9242e3e2d5e..eeb947663bf 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -38,7 +38,7 @@ def get_state(data: dict[str, float], key: str) -> str | float: return round(kb_spd, 2 if kb_spd < 0.1 else 1) -@dataclass +@dataclass(frozen=True) class DelugeSensorEntityDescription(SensorEntityDescription): """Class to describe a Deluge sensor.""" diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index ebe7e60af7b..35b79b57f1d 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -32,14 +32,14 @@ def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: ) -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[DevoloBinarySensorEntity], bool] -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorEntityDescription( BinarySensorEntityDescription, DevoloBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 463356268a6..9b3dd75ef98 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -22,14 +22,14 @@ from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS from .entity import DevoloEntity -@dataclass +@dataclass(frozen=True) class DevoloButtonRequiredKeysMixin: """Mixin for required keys.""" press_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloButtonEntityDescription( ButtonEntityDescription, DevoloButtonRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 3670c42bc6b..72cf4f57c1d 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -21,14 +21,14 @@ from .const import DOMAIN, IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloImageRequiredKeysMixin: """Mixin for required keys.""" image_func: Callable[[WifiGuestAccessGet], bytes] -@dataclass +@dataclass(frozen=True) class DevoloImageEntityDescription( ImageEntityDescription, DevoloImageRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 5d2b768d547..66395e3a465 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -49,14 +49,14 @@ class DataRateDirection(StrEnum): TX = "tx_rate" -@dataclass +@dataclass(frozen=True) class DevoloSensorRequiredKeysMixin(Generic[_CoordinatorDataT]): """Mixin for required keys.""" value_func: Callable[[_CoordinatorDataT], float] -@dataclass +@dataclass(frozen=True) class DevoloSensorEntityDescription( SensorEntityDescription, DevoloSensorRequiredKeysMixin[_CoordinatorDataT] ): diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index e7bcee3f2ec..99c23f77d35 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -23,7 +23,7 @@ from .entity import DevoloCoordinatorEntity _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) -@dataclass +@dataclass(frozen=True) class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): """Mixin for required keys.""" @@ -32,7 +32,7 @@ class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): turn_off_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloSwitchEntityDescription( SwitchEntityDescription, DevoloSwitchRequiredKeysMixin[_DataT] ): diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 1c95c4262b2..03f86381307 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -26,7 +26,7 @@ from .const import DOMAIN, REGULAR_FIRMWARE from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloUpdateRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class DevoloUpdateRequiredKeysMixin: update_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloUpdateEntityDescription( UpdateEntityDescription, DevoloUpdateRequiredKeysMixin ): diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 9648492c2e4..df16551fff2 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -35,7 +35,7 @@ def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | return None -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DiscovergySensorEntityDescription(SensorEntityDescription): """Class to describe a Discovergy sensor entity.""" diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index 1c69429d3c7..1e1b4c55e18 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -17,14 +17,14 @@ from .models import DoorBirdData IR_RELAY = "__ir_light__" -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescriptionMixin: """Mixin to describe a Doorbird Button entity.""" press_action: Callable[[DoorBird, str], None] -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescription( ButtonEntityDescription, DoorbirdButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 6cfbdd50b34..2ec2b0a1c91 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import DormakabaDkeyEntity from .models import DormakabaDkeyData -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescriptionMixin: """Class for keys required by Dormakaba dKey binary sensor entity.""" is_on: Callable[[Notifications], bool] -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescription( BinarySensorEntityDescription, DormakabaDkeyBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index f84261de44b..4de20bf86e8 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -30,14 +30,14 @@ SENSOR_NETWORK = "network" SENSOR_SMS_UNREAD = "sms" -@dataclass +@dataclass(frozen=True) class DovadoRequiredKeysMixin: """Mixin for required keys.""" identifier: str -@dataclass +@dataclass(frozen=True) class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): """Describes Dovado sensor entity.""" diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 3a92bfe5510..22c2a1a9557 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityMixin: """Mixin for Dremel 3D Printer binary sensor.""" value_fn: Callable[[Dremel3DPrinter], bool] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityDescription( BinarySensorEntityDescription, Dremel3DPrinterBinarySensorEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index 2d328b30cea..b2ea103f78b 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -16,14 +16,14 @@ from .const import DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityMixin: """Mixin for required keys.""" press_fn: Callable[[Dremel3DPrinter], None] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityDescription( ButtonEntityDescription, Dremel3DPrinterButtonEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 660e7a90487..b24b01d2308 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -31,14 +31,14 @@ from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityMixin: """Mixin for Dremel 3D Printer sensor.""" value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityDescription( SensorEntityDescription, Dremel3DPrinterSensorEntityMixin ): diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 9c511ef9191..3e26ee1ea62 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -67,7 +67,7 @@ EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DSMRSensorEntityDescription(SensorEntityDescription): """Represents an DSMR Sensor.""" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index f12b2ad72bc..2b5b995eabd 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -38,7 +38,7 @@ def tariff_transform(value): return "high" -@dataclass +@dataclass(frozen=True) class DSMRReaderSensorEntityDescription(SensorEntityDescription): """Sensor entity description for DSMR Reader.""" diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 28bcbbafcb8..7298c49660f 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -29,7 +29,7 @@ from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EasyEnergySensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescription( SensorEntityDescription, EasyEnergySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 67c975010ab..345ca7b705f 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -18,7 +18,7 @@ from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescriptionBase: """Required values when describing Ecobee number entities.""" @@ -26,7 +26,7 @@ class EcobeeNumberEntityDescriptionBase: set_fn: Callable[[EcobeeData, int, int], Awaitable] -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescription( NumberEntityDescription, EcobeeNumberEntityDescriptionBase ): diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 4d07ec9447e..7f0e7b808a8 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -25,14 +25,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescriptionMixin: """Represent the required ecobee entity description attributes.""" runtime_key: str | None -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescription( SensorEntityDescription, EcobeeSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index 90ea0bd4dff..79d62b6a2d2 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -16,14 +16,14 @@ from .coordinator import EcoforestCoordinator from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], float | None] -@dataclass +@dataclass(frozen=True) class EcoforestNumberEntityDescription( NumberEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index e595ddb65f7..6f903bee2ba 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -33,14 +33,14 @@ STATUS_TYPE = [s.value for s in State] ALARM_TYPE = [a.value for a in Alarm] + ["none"] -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], StateType] -@dataclass +@dataclass(frozen=True) class EcoforestSensorEntityDescription( SensorEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index 32341ff5d61..1e70068cde8 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -17,7 +17,7 @@ from .coordinator import EcoforestCoordinator from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -25,7 +25,7 @@ class EcoforestSwitchRequiredKeysMixin: switch_fn: Callable[[EcoforestApi, bool], Awaitable[Device]] -@dataclass +@dataclass(frozen=True) class EcoforestSwitchEntityDescription( SwitchEntityDescription, EcoforestSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8017bbf006e..51d02781554 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -28,14 +28,14 @@ ATTR_EK_HOP_START = "hop_sensor_start" ATTR_EK_HOP_END = "hop_sensor_end" -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPRequiredKeysMixin: """Mixin for required HOP keys.""" value_func: Callable[[Hop], datetime] -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPSensorEntityDescription( SensorEntityDescription, ElectricKiwiHOPRequiredKeysMixin, diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 7a69db24012..9747496c126 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -23,7 +23,7 @@ from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoButtonEntityDescription(ButtonEntityDescription): """Class describing Elgato button entities.""" diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 27dedee25c9..b683b80f5fa 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -26,7 +26,7 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoSensorEntityDescription(SensorEntityDescription): """Class describing Elgato sensor entities.""" diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index e9ab506c3a4..d1f370547a4 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -19,7 +19,7 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoSwitchEntityDescription(SwitchEntityDescription): """Class describing Elgato switch entities.""" diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 2468e5e68bf..59c44c1aad8 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -29,7 +29,7 @@ from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EnergyZeroSensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescription( SensorEntityDescription, EnergyZeroSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index f63fd7239d0..83c801d598e 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -44,14 +44,14 @@ SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescriptionMixin: """Mixin for required keys.""" unique_id: Callable[[list[int]], str | None] -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescription( SensorEntityDescription, EnOceanSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 7060943deb8..5eb2e621e47 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -22,14 +22,14 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): @@ -53,14 +53,14 @@ ENCHARGE_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 918e4002e7a..bf54c91f45b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -25,21 +25,21 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyDryContactSettings], float] -@dataclass +@dataclass(frozen=True) class EnvoyRelayNumberEntityDescription( NumberEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay number entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -47,7 +47,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsNumberEntityDescription( NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 331d2a999ad..5d2edf91d9a 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -21,7 +21,7 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" @@ -31,14 +31,14 @@ class EnvoyRelayRequiredKeysMixin: ] -@dataclass +@dataclass(frozen=True) class EnvoyRelaySelectEntityDescription( SelectEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay select entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -46,7 +46,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSelectEntityDescription( SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 33b9e3a64df..1dfd72dcaf3 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -47,14 +47,14 @@ INVERTERS_KEY = "inverters" LAST_REPORTED_KEY = "last_reported" -@dataclass +@dataclass(frozen=True) class EnvoyInverterRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyInverter], datetime.datetime | float] -@dataclass +@dataclass(frozen=True) class EnvoyInverterSensorEntityDescription( SensorEntityDescription, EnvoyInverterRequiredKeysMixin ): @@ -80,14 +80,14 @@ INVERTER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] -@dataclass +@dataclass(frozen=True) class EnvoyProductionSensorEntityDescription( SensorEntityDescription, EnvoyProductionRequiredKeysMixin ): @@ -137,14 +137,14 @@ PRODUCTION_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionSensorEntityDescription( SensorEntityDescription, EnvoyConsumptionRequiredKeysMixin ): @@ -194,28 +194,28 @@ CONSUMPTION_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): """Describes an Envoy Encharge sensor entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargePower], int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerSensorEntityDescription( SensorEntityDescription, EnvoyEnchargePowerRequiredKeysMixin ): @@ -259,14 +259,14 @@ ENCHARGE_POWER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSensorEntityDescription( SensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): @@ -289,14 +289,14 @@ ENPOWER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargeAggregate], int] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeAggregateRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 22746fd9479..76c73914db6 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -24,7 +24,7 @@ from .entity import EnvoyBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" @@ -33,14 +33,14 @@ class EnvoyEnpowerRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSwitchEntityDescription( SwitchEntityDescription, EnvoyEnpowerRequiredKeysMixin ): """Describes an Envoy Enpower switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyDryContactRequiredKeysMixin: """Mixin for required keys.""" @@ -49,14 +49,14 @@ class EnvoyDryContactRequiredKeysMixin: turn_off_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyDryContactSwitchEntityDescription( SwitchEntityDescription, EnvoyDryContactRequiredKeysMixin ): """Describes an Envoy Enpower dry contact switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -65,7 +65,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSwitchEntityDescription( SwitchEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 987a779d2e8..9ec4971f573 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -33,14 +33,14 @@ from .const import ATTR_STATION, DOMAIN ATTR_TIME = "alert time" -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Any], Any] -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescription( SensorEntityDescription, ECSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 4dd16b23480..1cdda152685 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -33,14 +33,14 @@ SCAN_INTERVAL = timedelta(seconds=60) PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescriptionMixin: """Mixin values for EZVIZ Alarm control panel entities.""" ezviz_alarm_states: list -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, EzvizAlarmControlPanelEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 2199f82a476..abc44419075 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -22,7 +22,7 @@ from .entity import EzvizEntity PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescriptionMixin: """Mixin values for EZVIZ button entities.""" @@ -30,7 +30,7 @@ class EzvizButtonEntityDescriptionMixin: supported_ext: str -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescription( ButtonEntityDescription, EzvizButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index ea7a4812b32..c922173aa87 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescriptionMixin: """Mixin values for EZVIZ Number entities.""" @@ -38,7 +38,7 @@ class EzvizNumberEntityDescriptionMixin: supported_ext_value: list -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescription( NumberEntityDescription, EzvizNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 369a429dbe6..8110cf61a5c 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -20,14 +20,14 @@ from .entity import EzvizEntity PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescriptionMixin: """Mixin values for EZVIZ Select entities.""" supported_switch: int -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescription( SelectEntityDescription, EzvizSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 4089b0ae393..f6d19afae0c 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -22,14 +22,14 @@ from .coordinator import EzvizDataUpdateCoordinator from .entity import EzvizEntity -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescriptionMixin: """Mixin values for EZVIZ Switch entities.""" supported_ext: str | None -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescription( SwitchEntityDescription, EzvizSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index c72fedaf59a..20bebcf08c8 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -21,7 +21,7 @@ from . import FAADataUpdateCoordinator from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): """Mixin for required keys.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e2cfb3e3992..eb7d3b02b4d 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -146,7 +146,7 @@ def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]: return convert -@dataclass +@dataclass(frozen=True) class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index 153732d2ce5..ee46067f443 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -14,7 +14,7 @@ from .const import DOMAIN, NAME_STATUS from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMBinarySensorEntityDescription( BinarySensorEntityDescription, FiveMEntityDescription ): diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py index c11378ff049..69204b559ae 100644 --- a/homeassistant/components/fivem/entity.py +++ b/homeassistant/components/fivem/entity.py @@ -16,7 +16,7 @@ from .coordinator import FiveMDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FiveMEntityDescription(EntityDescription): """Describes FiveM entity.""" diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 1c4e4b77c45..967a1392fe5 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -24,7 +24,7 @@ from .const import ( from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescription): """Describes FiveM sensor entity.""" diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 41cdc0dbffe..03302d490a6 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -22,7 +22,7 @@ from . import async_setup_entry_platform from .coordinator import FjaraskupanCoordinator -@dataclass +@dataclass(frozen=True) class EntityDescription(BinarySensorEntityDescription): """Entity description.""" diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 2305cd9f23e..fd6fcc5f4b9 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -39,14 +39,14 @@ BINARY_SENSOR_DESCRIPTION_CONNECTED = BinarySensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorRequiredKeysMixin: """Mixin for required keys.""" event_rule: str -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorEntityDescription( BinarySensorEntityDescription, FlumeBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 7a2723ce591..68a3fe81867 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,7 +27,7 @@ from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): """Describes a Forecast.Solar Sensor.""" diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index e3a206b43a8..d1268fb91d2 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -18,14 +18,14 @@ from .const import DOMAIN from .router import FreeboxRouter -@dataclass +@dataclass(frozen=True) class FreeboxButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[FreeboxRouter], Awaitable] -@dataclass +@dataclass(frozen=True) class FreeboxButtonEntityDescription( ButtonEntityDescription, FreeboxButtonRequiredKeysMixin ): diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6d371a82c95..00e9f406ed4 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescription ): diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a4504996820..5b4a3f5a20c 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -23,14 +23,14 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable -@dataclass +@dataclass(frozen=True) class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 2abba137fbf..63f9f593ea8 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1092,14 +1092,14 @@ class FritzBoxBaseEntity: ) -@dataclass +@dataclass(frozen=True) class FritzRequireKeysMixin: """Fritz entity description mix in.""" value_fn: Callable[[FritzStatus, Any], Any] | None -@dataclass +@dataclass(frozen=True) class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): """Fritz entity base description.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index d6b78c1cfc0..53a299cd576 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -142,7 +142,7 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 80cbe1f4c5c..fafd9c37ab8 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -21,7 +21,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): """Describes Fritz update entity.""" diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index e36056d2fab..c6676bb1bbf 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -22,14 +22,14 @@ from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): """BinarySensor description mixin for Fritz!Smarthome entities.""" is_on: Callable[[FritzhomeDevice], bool | None] -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor ): diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 3c3275e0ff0..74c5bd42927 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -18,7 +18,7 @@ class ClimateExtraAttributes(TypedDict, total=False): window_open: bool -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBase: """Bases description mixin for Fritz!Smarthome entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 140ecaef331..fd55369d915 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -35,14 +35,14 @@ from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): """Sensor description mixin for Fritz!Smarthome entities.""" native_value: Callable[[FritzhomeDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription( SensorEntityDescription, FritzEntityDescriptionMixinSensor ): diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index f058a25a044..93c13c8e579 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -104,7 +104,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index b16265ed467..0a6233937ae 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -22,14 +22,14 @@ from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescriptionMixin: """Mixin to describe a Fully Kiosk Browser button entity.""" press_action: Callable[[FullyKiosk], Any] -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescription( ButtonEntityDescription, FullyButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index dd775e7d55a..8e9029fda73 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -40,7 +40,7 @@ def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: return (url, extra_state_attributes) -@dataclass +@dataclass(frozen=True) class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index c1d5d4e5c75..d5480b784c4 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -18,7 +18,7 @@ from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullySwitchEntityDescriptionMixin: """Fully Kiosk Browser switch entity description mixin.""" @@ -29,7 +29,7 @@ class FullySwitchEntityDescriptionMixin: mqtt_off_event: str | None -@dataclass +@dataclass(frozen=True) class FullySwitchEntityDescription( SwitchEntityDescription, FullySwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b66cb8cd00d..bf905bc551d 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -20,7 +20,7 @@ from .const import DOMAIN from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 1ed738a9690..cbdbda5f367 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -16,7 +16,7 @@ from .const import DOMAIN from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index f0ba5dbd2fe..ef19a921041 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -29,7 +29,7 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 495a1fcb1eb..ca2b1acdd8c 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -27,7 +27,7 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index 541d2e0b89d..dd324492d73 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -18,14 +18,14 @@ from .const import DOMAIN from .coordinator import GeocachingDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class GeocachingRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[GeocachingStatus], str | int | None] -@dataclass +@dataclass(frozen=True) class GeocachingSensorEntityDescription( SensorEntityDescription, GeocachingRequiredKeysMixin ): diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f5bbdb06198..9ca34b2e77c 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -42,14 +42,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GiosSensorRequiredKeysMixin: """Class for GIOS entity required keys.""" value: Callable[[GiosSensors], StateType] -@dataclass +@dataclass(frozen=True) class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): """Class describing GIOS sensor entities.""" diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index d497700f5db..cec0e6b763f 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -22,14 +22,14 @@ from .const import DOMAIN from .coordinator import GitHubDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required GitHub base description keys.""" value_fn: Callable[[dict[str, Any]], StateType] -@dataclass +@dataclass(frozen=True) class BaseEntityDescription(SensorEntityDescription): """Describes GitHub sensor entity default overrides.""" @@ -38,7 +38,7 @@ class BaseEntityDescription(SensorEntityDescription): avabl_fn: Callable[[dict[str, Any]], bool] = lambda data: True -@dataclass +@dataclass(frozen=True) class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescriptionMixin): """Describes GitHub issue sensor entity.""" diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 78aa5ffbf0a..a3578bf6f66 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -30,7 +30,7 @@ from . import GlancesDataUpdateCoordinator from .const import CPU_ICON, DOMAIN -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class GlancesSensorEntityDescriptionMixin: name_suffix: str -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescription( SensorEntityDescription, GlancesSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index 12cad42547d..0aebdb8c073 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -18,14 +18,14 @@ from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescriptionRequired: """Required attributes of GoodweButtonEntityDescription.""" action: Callable[[Inverter], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescription( ButtonEntityDescription, GoodweButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index a3e4190f309..d92f6ab8fd0 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -23,7 +23,7 @@ from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescriptionBase: """Required values when describing Goodwe number entities.""" @@ -32,7 +32,7 @@ class GoodweNumberEntityDescriptionBase: filter: Callable[[Inverter], bool] -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescription( NumberEntityDescription, GoodweNumberEntityDescriptionBase ): diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 0065d70dda9..a43ff971a9a 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -75,7 +75,7 @@ _ICONS: dict[SensorKind, str] = { } -@dataclass +@dataclass(frozen=True) class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 6bf552b824b..f90cc028fdf 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -42,7 +42,7 @@ ENDPOINT = "/api/v1/status" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -@dataclass +@dataclass(frozen=True) class GoogleWifiRequiredKeysMixin: """Mixin for required keys.""" @@ -50,7 +50,7 @@ class GoogleWifiRequiredKeysMixin: sensor_key: str -@dataclass +@dataclass(frozen=True) class GoogleWifiSensorEntityDescription( SensorEntityDescription, GoogleWifiRequiredKeysMixin ): diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 3c1893f7735..b9c8491e0f1 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -21,7 +21,7 @@ from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity -@dataclass +@dataclass(frozen=True) class GreeRequiredKeysMixin: """Mixin for required keys.""" @@ -29,7 +29,7 @@ class GreeRequiredKeysMixin: set_value_fn: Callable[[Device, bool], None] -@dataclass +@dataclass(frozen=True) class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" diff --git a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py index cd286e228b4..cfeb98a382e 100644 --- a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py @@ -6,14 +6,14 @@ from dataclasses import dataclass from homeassistant.components.sensor import SensorEntityDescription -@dataclass +@dataclass(frozen=True) class GrowattRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index bd2cb8c96de..1cb55204240 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -410,14 +410,14 @@ class PairedSensorEntity(GuardianEntity): self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" -@dataclass +@dataclass(frozen=True) class ValveControllerEntityDescriptionMixin: """Define an entity description mixin for valve controller entities.""" api_category: str -@dataclass +@dataclass(frozen=True) class ValveControllerEntityDescription( EntityDescription, ValveControllerEntityDescriptionMixin ): diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7114d33f93a..179158ab512 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -39,7 +39,7 @@ SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -@dataclass +@dataclass(frozen=True) class ValveControllerBinarySensorDescription( BinarySensorEntityDescription, ValveControllerEntityDescription ): diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index c6363c9bcec..7a931f35019 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -23,14 +23,14 @@ from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescript from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN -@dataclass +@dataclass(frozen=True) class GuardianButtonEntityDescriptionMixin: """Define an mixin for button entities.""" push_action: Callable[[Client], Awaitable] -@dataclass +@dataclass(frozen=True) class ValveControllerButtonDescription( ButtonEntityDescription, ValveControllerEntityDescription, diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index c5fc77cc8f9..68833234b15 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -39,7 +39,7 @@ SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -@dataclass +@dataclass(frozen=True) class ValveControllerSensorDescription( SensorEntityDescription, ValveControllerEntityDescription ): diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 4e2be5ae179..98179c1922f 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -29,7 +29,7 @@ SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" -@dataclass +@dataclass(frozen=True) class SwitchDescriptionMixin: """Define an entity description mixin for Guardian switches.""" @@ -37,7 +37,7 @@ class SwitchDescriptionMixin: on_action: Callable[[Client], Awaitable] -@dataclass +@dataclass(frozen=True) class ValveControllerSwitchDescription( SwitchEntityDescription, ValveControllerEntityDescription, SwitchDescriptionMixin ): diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index e2cd1bae270..f57cfa472c4 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -17,7 +17,7 @@ from .const import ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS from .entity import HassioAddonEntity -@dataclass +@dataclass(frozen=True) class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): """Hassio binary sensor entity description.""" diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index ff61c632be9..1c16b2c6483 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -28,7 +28,7 @@ from .entity import CharacteristicEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HomeKitButtonEntityDescription(ButtonEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 09bb57923c6..e6eae1c51ca 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -19,14 +19,14 @@ from .connection import HKDevice from .entity import CharacteristicEntity -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescriptionRequired: """Required fields for HomeKitSelectEntityDescription.""" choices: dict[str, IntEnum] -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescription( SelectEntityDescription, HomeKitSelectEntityDescriptionRequired ): diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2d30de24650..eb5b99e126d 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -46,7 +46,7 @@ from .entity import CharacteristicEntity, HomeKitEntity from .utils import folded_name -@dataclass +@dataclass(frozen=True) class HomeKitSensorEntityDescription(SensorEntityDescription): """Describes Homekit sensor.""" diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 15a7aca4a5d..2ae19152b93 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -30,7 +30,7 @@ ATTR_IS_CONFIGURED = "is_configured" ATTR_REMAINING_DURATION = "remaining_duration" -@dataclass +@dataclass(frozen=True) class DeclarativeSwitchEntityDescription(SwitchEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index d980e66e0e4..12655dbbc39 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -35,7 +35,7 @@ from .entity import HomeWizardEntity PARALLEL_UPDATES = 1 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 3f854aad320..fea4d7018bf 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,7 +23,7 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 9542648b996..0841b7df1cc 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -36,7 +36,7 @@ def _get_temperature_sensor_unit(device: Device) -> str: return UnitOfTemperature.FAHRENHEIT -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -44,7 +44,7 @@ class HoneywellSensorEntityDescriptionMixin: unit_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescription( SensorEntityDescription, HoneywellSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index 2f4b7274fc0..83b5d5545cb 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -26,14 +26,14 @@ from .const import DOMAIN, KEY_NET_NET_MODE _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HuaweiSelectEntityMixin: """Mixin for Huawei LTE select entities, to ensure required fields are set.""" setter_fn: Callable[[str], None] -@dataclass +@dataclass(frozen=True) class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): """Class describing Huawei LTE select entities.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index ca3734bb305..d47305fa5f6 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -111,7 +111,7 @@ class HuaweiSensorGroup: exclude: re.Pattern[str] | None = None -@dataclass +@dataclass(frozen=True) class HuaweiSensorEntityDescription(SensorEntityDescription): """Class describing Huawei LTE sensor entities.""" diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index b82b2b34a4b..82cf51d3b26 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -48,7 +48,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HuisbaasjeSensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 2e0bc1c413a..cb6bc72954f 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -23,14 +23,14 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[BaseShade], Any] -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescription( ButtonEntityDescription, PowerviewButtonDescriptionMixin ): diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 151b3a58011..65fe61851df 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -27,7 +27,7 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescriptionMixin: """Mixin to describe a select entity.""" @@ -35,7 +35,7 @@ class PowerviewSelectDescriptionMixin: select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescription( SelectEntityDescription, PowerviewSelectDescriptionMixin ): diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 330e5dddfa5..8e16d53ae09 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -33,7 +33,7 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" @@ -42,7 +42,7 @@ class PowerviewSensorDescriptionMixin: create_sensor_fn: Callable[[BaseShade], bool] -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescription( SensorEntityDescription, PowerviewSensorDescriptionMixin ): diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index f36eca93f28..df3a873b6c1 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -185,7 +185,7 @@ class IammeterSensor(update_coordinator.CoordinatorEntity, SensorEntity): return raw_attr -@dataclass +@dataclass(frozen=True) class IammeterSensorEntityDescription(SensorEntityDescription): """Describes Iammeter sensor entity.""" diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index b3895ce23b4..3ce145fc3b9 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -23,14 +23,14 @@ from .coordinator import IBeaconCoordinator from .entity import IBeaconEntity -@dataclass +@dataclass(frozen=True) class IBeaconRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[iBeaconAdvertisement], str | int | None] -@dataclass +@dataclass(frozen=True) class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKeysMixin): """Describes iBeacon sensor entity.""" diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py index 6cae9a42895..d11738c6bcd 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -21,7 +21,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescriptionMixin: """Mixin to describe a IdasenDesk button entity.""" @@ -30,7 +30,7 @@ class IdasenDeskButtonDescriptionMixin: ] -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescription( ButtonEntityDescription, IdasenDeskButtonDescriptionMixin ): diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index b67dec0f579..f4e04ea762b 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -22,14 +22,14 @@ from . import DeskData, IdasenDeskCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class IdasenDeskSensorDescriptionMixin: """Required values for IdasenDesk sensors.""" value_fn: Callable[[IdasenDeskCoordinator], float | None] -@dataclass +@dataclass(frozen=True) class IdasenDeskSensorDescription( SensorEntityDescription, IdasenDeskSensorDescriptionMixin, diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9e8cabbe253..535d8b61653 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -23,7 +23,7 @@ INCOMFORT_PRESSURE = "CV Pressure" INCOMFORT_TAP_TEMP = "Tap Temp" -@dataclass +@dataclass(frozen=True) class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index b19c592a5cf..503b97f183d 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -21,14 +21,14 @@ from .const import DOMAIN from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorEntityDescription( BinarySensorEntityDescription, IntellifireBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 3911efeb5b9..7c376eeec4c 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -26,7 +26,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireFanRequiredKeysMixin: """Required keys for fan entity.""" @@ -35,7 +35,7 @@ class IntellifireFanRequiredKeysMixin: speed_range: tuple[int, int] -@dataclass +@dataclass(frozen=True) class IntellifireFanEntityDescription( FanEntityDescription, IntellifireFanRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 05994919296..a807735ed79 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -22,7 +22,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireLightRequiredKeysMixin: """Required keys for fan entity.""" @@ -30,7 +30,7 @@ class IntellifireLightRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireLightEntityDescription( LightEntityDescription, IntellifireLightRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index bc42b977f12..c974378fb71 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -24,14 +24,14 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireSensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], int | str | datetime | None] -@dataclass +@dataclass(frozen=True) class IntellifireSensorEntityDescription( SensorEntityDescription, IntellifireSensorRequiredKeysMixin, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 1af4d8c0e91..03e3a2be0a2 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -18,7 +18,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass() +@dataclass(frozen=True) class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -27,7 +27,7 @@ class IntellifireSwitchRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireSwitchEntityDescription( SwitchEntityDescription, IntellifireSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 7dd26c46201..4faac347c40 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -36,7 +36,7 @@ from .coordinator import IotawattUpdater _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IotaWattSensorEntityDescription(SensorEntityDescription): """Class describing IotaWatt sensor entities.""" diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index cb0620ceca0..d779a7ae02a 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -23,14 +23,14 @@ from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IPMARequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] -@dataclass +@dataclass(frozen=True) class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin): """Describes IPMA sensor entity.""" diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index a2cb5cd34dc..d1acbe9bd96 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -37,14 +37,14 @@ from .coordinator import IPPDataUpdateCoordinator from .entity import IPPEntity -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Printer], StateType | datetime] -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescription( SensorEntityDescription, IPPSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index de64741ba3a..da208dcc79c 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -31,7 +31,7 @@ from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity from .models import IsyData -@dataclass +@dataclass(frozen=True) class ISYSwitchEntityDescription(SwitchEntityDescription): """Describes IST switch.""" diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index cd0e9ab21a2..0f1afd30e9b 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -16,14 +16,14 @@ from .entity import JellyfinEntity from .models import JellyfinData -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JellyfinDataT], StateType] -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescription( SensorEntityDescription, JellyfinSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index e127d78229f..638d54d6159 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -22,14 +22,14 @@ import homeassistant.util.dt as dt_util from . import DOMAIN -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" is_on: Callable[..., bool] = lambda _: False -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorEntityDescription( JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription ): diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index e78f6189baf..fd2535c5bf3 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -19,14 +19,14 @@ from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescriptionMixin: """Mixin for required keys.""" setter_key: str -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescription( NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 156fa37e982..cb428fa5eea 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -29,14 +29,14 @@ from .const import DOMAIN, VOLUME_FLOW_RATE_LITERS_PER_MINUTE from .entity import JustNimbusEntity -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JustNimbusCoordinator], Any] -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescription( SensorEntityDescription, JustNimbusEntityDescriptionMixin ): diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index ab487aa1a25..bb780aab619 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -17,14 +17,14 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DISPATCHER_KAITERRA, DOMAIN -@dataclass +@dataclass(frozen=True) class KaiterraSensorRequiredKeysMixin: """Mixin for required keys.""" suffix: str -@dataclass +@dataclass(frozen=True) class KaiterraSensorEntityDescription( SensorEntityDescription, KaiterraSensorRequiredKeysMixin ): diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 183036f3973..ba9eaca1e95 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -22,14 +22,14 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import StateType -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required descriptor keys.""" value_fn: Callable[[KaleidescapeDevice], StateType] -@dataclass +@dataclass(frozen=True) class KaleidescapeSensorEntityDescription( SensorEntityDescription, BaseEntityDescriptionMixin ): diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index dbfe8e9bd5e..2f09f7e8ed6 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -40,7 +40,7 @@ from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) -@dataclass +@dataclass(frozen=True) class KNXSystemEntityDescription(SensorEntityDescription): """Class describing KNX system sensor entities.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 834057d63b8..36e1fc95eb8 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -26,7 +26,7 @@ from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescriptionMixin: """Define an entity description mixin for number entities.""" @@ -36,7 +36,7 @@ class PlenticoreNumberEntityDescriptionMixin: fmt_to: str -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescription( NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 779cc24b0c4..321bc4e5d70 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -19,14 +19,14 @@ from .helper import Plenticore, SelectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore select entities.""" module_id: str -@dataclass +@dataclass(frozen=True) class PlenticoreSelectEntityDescription( SelectEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index ce18867511d..111d497b128 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -33,7 +33,7 @@ from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore sensor entities.""" @@ -41,7 +41,7 @@ class PlenticoreRequiredKeysMixin: formatter: str -@dataclass +@dataclass(frozen=True) class PlenticoreSensorEntityDescription( SensorEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 554f8db2b68..509a3610884 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -20,7 +20,7 @@ from .helper import SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore switch entities.""" @@ -32,7 +32,7 @@ class PlenticoreRequiredKeysMixin: off_label: str -@dataclass +@dataclass(frozen=True) class PlenticoreSwitchEntityDescription( SwitchEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 21eb3f2e5a1..7e55da2b189 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -32,14 +32,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class KrakenRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] -@dataclass +@dataclass(frozen=True) class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): """Describes Kraken sensor entity.""" diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 76688af61ae..e347a1409f6 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -35,14 +35,14 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Sensor, str], float | int | str | None] -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescription( SensorEntityDescription, LaCrosseSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 1de8c1d1717..dacbf8d2445 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricButtonEntityDescription(ButtonEntityDescription): """Class describing LaMetric button entities.""" diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index d8c70494264..9acdc6f1411 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index f15147235ac..c7a3f55125b 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSelectEntityDescription(SelectEntityDescription): """Class describing LaMetric select entities.""" diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 88d461e9d4f..5ef3608d33b 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -21,7 +21,7 @@ from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSensorEntityDescription(SensorEntityDescription): """Class describing LaMetric sensor entities.""" diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index ace492fe0cb..7fda3a22b8f 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index d7485e88fb0..075aeb67b50 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -39,14 +39,14 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescriptionMixin: """Mixin for additional Heat Meter sensor description attributes .""" value_fn: Callable[[HeatMeterResponse], StateType | datetime] -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescription( SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 5dab7da56ed..2c1934f0c16 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -31,7 +31,7 @@ from .const import DOMAIN DEFAULT_NEXT_LAUNCH_NAME = "Next launch" -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -39,7 +39,7 @@ class LaunchLibrarySensorEntityDescriptionMixin: attributes_fn: Callable[[Launch | Event], dict[str, Any] | None] -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescription( SensorEntityDescription, LaunchLibrarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 552bc35768f..027779f93fe 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -48,14 +48,14 @@ def get_modified_description( return desc, name -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescription( SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 0872c5c831d..6a588c36d6c 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot binary sensor entity required keys.""" is_on_fn: Callable[[_RobotT], bool] -@dataclass +@dataclass(frozen=True) class RobotBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredKeysMixin[_RobotT] ): diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 06c4fe75888..de93ead5190 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -46,14 +46,14 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot button entity required keys.""" press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot button entities.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 7f2ea62f956..726cfaebaeb 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -28,7 +28,7 @@ BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { } -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" @@ -37,7 +37,7 @@ class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSelectEntityDescription( SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT] ): diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 935bbaca595..a25921e440c 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -35,7 +35,7 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str return "mdi:gauge-low" -@dataclass +@dataclass(frozen=True) class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): """A class that describes robot sensor entities.""" diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 6b4e5b56b48..84e6fa2be67 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -18,7 +18,7 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot switch entity required keys.""" @@ -26,7 +26,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot switch entities.""" diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index f352b7cee70..bb840e17a8f 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -20,7 +20,7 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot time entity required keys.""" @@ -28,7 +28,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot time entities.""" diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f0a4cdfbb99..1b9af351e71 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -41,7 +41,7 @@ LYRIC_SETPOINT_STATUS_NAMES = { } -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -49,7 +49,7 @@ class LyricSensorEntityDescriptionMixin: suitable_fn: Callable[[LyricDevice], bool] -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescription( SensorEntityDescription, LyricSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index aabfc12eefb..ea87fabf3f5 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterBinarySensorEntityDescription( BinarySensorEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index de6e6ff83c2..e308699acad 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -37,7 +37,7 @@ LOGGER = logging.getLogger(__name__) EXTRA_POLL_DELAY = 3.0 -@dataclass +@dataclass(frozen=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 6262eb253aa..e7b18f308f7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): """Describe Matter sensor entities.""" diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 98bb44947c8..a7e03ae7c22 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -27,7 +27,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescriptionMixin: """Mixin for MeaterSensorEntityDescription.""" @@ -35,7 +35,7 @@ class MeaterSensorEntityDescriptionMixin: value: Callable[[MeaterProbe], datetime | float | str | None] -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescription( SensorEntityDescription, MeaterSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 1cb8930049d..cf53fe42b77 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -23,7 +23,7 @@ from . import MelCloudDevice from .const import DOMAIN -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class MelcloudRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelcloudRequiredKeysMixin: enabled: Callable[[Any], bool] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class MelcloudSensorEntityDescription( SensorEntityDescription, MelcloudRequiredKeysMixin ): diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index e0f9c7d3bf6..caf2d499851 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -26,7 +26,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescriptionMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class MelnorZoneNumberEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescription( NumberEntityDescription, MelnorZoneNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index edb906cc80f..255c3c9747d 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -54,28 +54,28 @@ def next_cycle(valve: Valve) -> datetime | None: return None -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescription( SensorEntityDescription, MelnorZoneSensorEntityDescriptionMixin ): """Describes Melnor sensor entity.""" -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescription( SensorEntityDescription, MelnorSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 03bd28faa9d..e3c0e0afa15 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -25,7 +25,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -33,7 +33,7 @@ class MelnorSwitchEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescription( SwitchEntityDescription, MelnorSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 943a7996aeb..36afe2d976d 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -23,7 +23,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelnorZoneTimeEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescription( TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin ): diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index dd8fd4af83b..451d617e65b 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -51,14 +51,14 @@ from .const import ( _DataT = TypeVar("_DataT", bound=Rain | Forecast | CurrentPhenomenons) -@dataclass +@dataclass(frozen=True) class MeteoFranceRequiredKeysMixin: """Mixin for required keys.""" data_path: str -@dataclass +@dataclass(frozen=True) class MeteoFranceSensorEntityDescription( SensorEntityDescription, MeteoFranceRequiredKeysMixin ): diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 520d7342b35..6c0a2a248f3 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -19,7 +19,7 @@ ICON_STATUS = "mdi:lan" KEY_STATUS = "status" -@dataclass +@dataclass(frozen=True) class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Minecraft Server binary sensor entities.""" diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 661ce00dac5..671bbdb7a05 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -41,7 +41,7 @@ UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" -@dataclass +@dataclass(frozen=True) class MinecraftServerEntityDescriptionMixin: """Mixin values for Minecraft Server entities.""" @@ -50,7 +50,7 @@ class MinecraftServerEntityDescriptionMixin: supported_server_types: set[MinecraftServerType] -@dataclass +@dataclass(frozen=True) class MinecraftServerSensorEntityDescription( SensorEntityDescription, MinecraftServerEntityDescriptionMixin ): diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 2b4edd99221..b70a7fc8d55 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -21,7 +21,7 @@ from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload -@dataclass +@dataclass(frozen=True) class MySensorsBinarySensorDescription(BinarySensorEntityDescription): """Describe a MySensors binary sensor entity.""" diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 606a6275acf..4551c9ebbec 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, MANUFACTURER -@dataclass +@dataclass(frozen=True) class MyStromSwitchSensorEntityDescription(SensorEntityDescription): """Class describing mystrom switch sensor entities.""" diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 3c0b8bc9ba4..5b3c6517f64 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -74,14 +74,14 @@ PARALLEL_UPDATES = 1 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class NAMSensorRequiredKeysMixin: """Class for NAM entity required keys.""" value: Callable[[NAMSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysMixin): """NAM sensor entity description.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 2f99b866cf2..692a1a806ea 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -70,14 +70,14 @@ SUPPORTED_PUBLIC_SENSOR_TYPES: tuple[str, ...] = ( ) -@dataclass +@dataclass(frozen=True) class NetatmoRequiredKeysMixin: """Mixin for required keys.""" netatmo_name: str -@dataclass +@dataclass(frozen=True) class NetatmoSensorEntityDescription(SensorEntityDescription, NetatmoRequiredKeysMixin): """Describes Netatmo sensor entity.""" diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index f3283f8d7b5..6ec988edbe1 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -19,14 +19,14 @@ from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescriptionRequired: """Required attributes of NetgearButtonEntityDescription.""" action: Callable[[NetgearRouter], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescription( ButtonEntityDescription, NetgearButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 6e7771d44cb..897fe9da30c 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -77,7 +77,7 @@ SENSOR_TYPES = { } -@dataclass +@dataclass(frozen=True) class NetgearSensorEntityDescription(SensorEntityDescription): """Class describing Netgear sensor entities.""" diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index a4548da16a4..4be13a0f32c 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -32,7 +32,7 @@ SWITCH_TYPES = [ ] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescriptionRequired: """Required attributes of NetgearSwitchEntityDescription.""" @@ -40,7 +40,7 @@ class NetgearSwitchEntityDescriptionRequired: action: Callable[[NetgearRouter], bool] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescription( SwitchEntityDescription, NetgearSwitchEntityDescriptionRequired ): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 6800c403ee8..851cb9f3cd3 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -30,7 +30,7 @@ from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" -@dataclass +@dataclass(frozen=True) class NextcloudSensorEntityDescription(SensorEntityDescription): """Describes Nextcloud sensor entity.""" diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index e2e37ccab2d..dad29893161 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -24,14 +24,14 @@ from .const import ATTR_CONNECTION, DOMAIN PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Mixin for required keys.""" state: Callable[[CoordinatorDataT, str], bool] -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorEntityDescription( BinarySensorEntityDescription, NextDnsBinarySensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index ccbbb5e534e..c501142697e 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -38,7 +38,7 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" @@ -46,7 +46,7 @@ class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): value: Callable[[CoordinatorDataT], StateType] -@dataclass +@dataclass(frozen=True) class NextDnsSensorEntityDescription( SensorEntityDescription, NextDnsSensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 0a310bc29e7..177b4970a93 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -24,14 +24,14 @@ from .const import ATTR_SETTINGS, DOMAIN PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSwitchRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" state: Callable[[CoordinatorDataT], bool] -@dataclass +@dataclass(frozen=True) class NextDnsSwitchEntityDescription( SwitchEntityDescription, NextDnsSwitchRequiredKeysMixin[CoordinatorDataT] ): diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index ff58d566a34..a1c519f228f 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -33,14 +33,14 @@ from .const import ( from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescriptionMixin: """Define an entity description mixin for binary and regular sensors.""" on_state: Literal["alarm", "leak", "low", "not_missing", "open"] -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescription( BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin, diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index 0999df3abdb..cdfd6e63dad 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from aionotion.sensor.models import ListenerKind -@dataclass +@dataclass(frozen=True) class NotionEntityDescriptionMixin: """Define an description mixin Notion entities.""" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 4777cc94fbf..8c4242aec2a 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -19,7 +19,7 @@ from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): """Describe a Notion sensor.""" diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index ecf9d39ae55..35fb6c0ec1f 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -39,7 +39,7 @@ from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class NWSSensorEntityDescription(SensorEntityDescription): """Class describing NWSSensor entities.""" diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index b405140bc32..2840cde704b 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -25,7 +25,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireBinarySensorEntityDescription( OneWireEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index a6eddece5c6..cad55234181 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import StateType from .const import READ_MODE_BOOL, READ_MODE_INT -@dataclass +@dataclass(frozen=True) class OneWireEntityDescription(EntityDescription): """Class describing OneWire entities.""" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 61cf3459c84..cc8b14b5d6e 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -42,7 +42,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): """Class describing OneWire sensor entities.""" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 986be11d169..db9e8f5b0f8 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -22,7 +22,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): """Class describing OneWire switch entities.""" diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index 4f7de67386b..673f77f558c 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -16,7 +16,7 @@ from .device import ONVIFDevice from .models import Profile -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class ONVIFSwitchEntityDescriptionMixin: supported_fn: Callable[[ONVIFDevice], bool] -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescription( SwitchEntityDescription, ONVIFSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 8434b6d5591..431fa41a288 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -71,14 +71,14 @@ def get_uv_label(uv_index: int) -> str: return label.value -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescriptionMixin: """Define a mixin for OpenUV sensor descriptions.""" value_fn: Callable[[dict[str, Any]], int | str] -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescription( SensorEntityDescription, OpenUvSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 175bef01449..9940132dac2 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,14 +24,14 @@ from .const import DOMAIN from .coordinator import OpowerCoordinator -@dataclass +@dataclass(frozen=True) class OpowerEntityDescriptionMixin: """Mixin values for required keys.""" value_fn: Callable[[Forecast], str | float] -@dataclass +@dataclass(frozen=True) class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin): """Class describing Opower sensors entities.""" diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index b08ede7df10..fcd94ae5bcd 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -35,7 +35,7 @@ from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -43,7 +43,7 @@ class OverkizAlarmDescriptionMixin: fn_state: Callable[[Callable[[str], OverkizStateType]], str] -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescription( AlarmControlPanelEntityDescription, OverkizAlarmDescriptionMixin ): diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 0d00179ee81..975ef4ff834 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -22,14 +22,14 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensor entities.""" value_fn: Callable[[OverkizStateType], bool] -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescription( BinarySensorEntityDescription, OverkizBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 8388e2c3b2d..f8f33db7eed 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -17,7 +17,7 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizButtonDescription(ButtonEntityDescription): """Class to describe an Overkiz button.""" diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index c90c4446339..c15a7bd3acc 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -26,14 +26,14 @@ BOOST_MODE_DURATION_DELAY = 1 OPERATING_MODE_DELAY = 3 -@dataclass +@dataclass(frozen=True) class OverkizNumberDescriptionMixin: """Define an entity description mixin for number entities.""" command: str -@dataclass +@dataclass(frozen=True) class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescriptionMixin): """Class to describe an Overkiz number.""" diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 5f72ca23a80..c225d475f63 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -17,14 +17,14 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSelectDescriptionMixin: """Define an entity description mixin for select entities.""" select_option: Callable[[str, Callable[..., Awaitable[None]]], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class OverkizSelectDescription(SelectEntityDescription, OverkizSelectDescriptionMixin): """Class to describe an Overkiz select entity.""" diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index a267b54b398..011daf2ab51 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -44,7 +44,7 @@ from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity, OverkizEntity -@dataclass +@dataclass(frozen=True) class OverkizSensorDescription(SensorEntityDescription): """Class to describe an Overkiz sensor.""" diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e77..0396e385a3c 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -24,7 +24,7 @@ from .const import DOMAIN from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -32,7 +32,7 @@ class OverkizSwitchDescriptionMixin: turn_off: str -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescription(SwitchEntityDescription, OverkizSwitchDescriptionMixin): """Class to describe an Overkiz switch.""" diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 89a05f99a81..761515c9c84 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -33,7 +33,7 @@ KEY_LAST_ELECTRICITY_COST: Final = "last_electricity_cost" KEY_LAST_GAS_COST: Final = "last_gas_cost" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class OVOEnergySensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 935f2b659f9..f9ad35fd251 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -24,7 +24,7 @@ from . import PECOCoordinatorData from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class PECOSensorEntityDescriptionMixin: attribute_fn: Callable[[PECOCoordinatorData], dict[str, str]] -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescription( SensorEntityDescription, PECOSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index cf229f16d12..5f7f431ddf7 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -21,14 +21,14 @@ from .coordinator import PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity -@dataclass +@dataclass(frozen=True) class PegelOnlineRequiredKeysMixin: """Mixin for required keys.""" measurement_key: str -@dataclass +@dataclass(frozen=True) class PegelOnlineSensorEntityDescription( SensorEntityDescription, PegelOnlineRequiredKeysMixin ): diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index e942aa265b8..a48741b0886 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -38,7 +38,7 @@ from .coordinator import MyPermobilCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PermobilRequiredKeysMixin: """Mixin for required keys.""" @@ -46,7 +46,7 @@ class PermobilRequiredKeysMixin: available_fn: Callable[[Any], bool] -@dataclass +@dataclass(frozen=True) class PermobilSensorEntityDescription( SensorEntityDescription, PermobilRequiredKeysMixin ): diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index ec93f0ab87e..74fe41bf722 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -18,7 +18,7 @@ from .const import DOMAIN from .entity import PhilipsJsEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): """A entity description for Philips TV binary sensor.""" diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5d1419db8b2..2f3a5a4801c 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -21,14 +21,14 @@ from . import PiHoleEntity from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN -@dataclass +@dataclass(frozen=True) class RequiredPiHoleBinaryDescription: """Represent the required attributes of the PiHole binary description.""" state_value: Callable[[Hole], bool] -@dataclass +@dataclass(frozen=True) class PiHoleBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredPiHoleBinaryDescription ): diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index b9d8bf828d4..b559a1cf806 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -17,7 +17,7 @@ from . import PiHoleEntity from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN -@dataclass +@dataclass(frozen=True) class PiHoleUpdateEntityDescription(UpdateEntityDescription): """Describes PiHole update entity.""" diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 507ab82e8e2..56d2d22cf29 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -44,7 +44,7 @@ from .const import ( from .coordinator import PicnicUpdateCoordinator -@dataclass +@dataclass(frozen=True) class PicnicRequiredKeysMixin: """Mixin for required keys.""" @@ -54,7 +54,7 @@ class PicnicRequiredKeysMixin: value_fn: Callable[[Any], StateType | datetime] -@dataclass +@dataclass(frozen=True) class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): """Describes Picnic sensor entity.""" diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 5da82ab4105..0c67e20d7ab 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -23,7 +23,7 @@ from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] -@dataclass +@dataclass(frozen=True) class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index c21ecbd94c7..c71b52cf5c8 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -22,7 +22,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PlugwiseNumberEntityDescription(NumberEntityDescription): """Class describing Plugwise Number entities.""" diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index eef873703c1..4be21fe9026 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -17,7 +17,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PlugwiseSelectEntityDescription(SelectEntityDescription): """Class describing Plugwise Select entities.""" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 0cc878178fe..95dfc2ba6a3 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -32,7 +32,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass +@dataclass(frozen=True) class PlugwiseSensorEntityDescription(SensorEntityDescription): """Describes Plugwise sensor entity.""" diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 8639826e37a..dfd11127332 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -22,7 +22,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -@dataclass +@dataclass(frozen=True) class PlugwiseSwitchEntityDescription(SwitchEntityDescription): """Describes Plugwise switch entity.""" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 462d8270f0a..471fa72c6c5 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -23,14 +23,14 @@ from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class MinutPointRequiredKeysMixin: """Mixin for required keys.""" precision: int -@dataclass +@dataclass(frozen=True) class MinutPointSensorEntityDescription( SensorEntityDescription, MinutPointRequiredKeysMixin ): diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 3f02c925f9d..bfa75392efb 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -32,14 +32,14 @@ _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" -@dataclass +@dataclass(frozen=True) class PowerwallRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Meter], float] -@dataclass +@dataclass(frozen=True) class PowerwallSensorEntityDescription( SensorEntityDescription, PowerwallRequiredKeysMixin ): diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index d15ed1163b7..fb094de3d58 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import BasePrivateDeviceEntity -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescriptionRequired: """Required domain specific fields for sensor entity.""" @@ -35,7 +35,7 @@ class PrivateDeviceSensorEntityDescriptionRequired: ] -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescription( SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired ): diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 7e95b209bad..a44de101387 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -18,14 +18,14 @@ from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator T = TypeVar("T", PrinterInfo, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" press_fn: Callable[[PrusaLink], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescription( ButtonEntityDescription, PrusaLinkButtonEntityDescriptionMixin[T], Generic[T] ): diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 1ee4274e5bb..c6feda0defd 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -27,14 +27,14 @@ from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator T = TypeVar("T", PrinterInfo, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T], datetime | StateType] -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescription( SensorEntityDescription, PrusaLinkSensorEntityDescriptionMixin[T], Generic[T] ): diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 4ab77fa7893..09470609c9e 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -22,14 +22,14 @@ from . import PureEnergieData, PureEnergieDataUpdateCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PureEnergieData], int | float] -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescription( SensorEntityDescription, PureEnergieSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index fffceffa343..1e78586dece 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -33,14 +33,14 @@ from .coordinator import PurpleAirDataUpdateCoordinator CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescriptionMixin: """Define a description mixin for PurpleAir sensor entities.""" value_fn: Callable[[SensorModel], float | str | None] -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescription( SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index d9ef71bee69..c003e3cfad8 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -28,7 +28,7 @@ from .const import CONF_SYSTEM_ID, DOMAIN from .coordinator import PVOutputDataUpdateCoordinator -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PVOutputSensorEntityDescription(SensorEntityDescription): """Describes a PVOutput sensor entity.""" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e2feee1e60c..0e6bc071125 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -29,14 +29,14 @@ SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -@dataclass +@dataclass(frozen=True) class QBittorrentMixin: """Mixin for required keys.""" value_fn: Callable[[dict[str, Any]], StateType] -@dataclass +@dataclass(frozen=True) class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): """Describes QBittorrent sensor entity.""" diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index 5c3fbe13aff..f655beee3d4 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -30,7 +30,7 @@ from .coordinator import QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswBinarySensorEntityDescription( BinarySensorEntityDescription, QswEntityDescription ): diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index acd8d3bd1ef..c2c4f9f6043 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -22,14 +22,14 @@ from .coordinator import QswDataCoordinator from .entity import QswDataEntity -@dataclass +@dataclass(frozen=True) class QswButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[QnapQswApi], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class QswButtonDescription(ButtonEntityDescription, QswButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 4bbfba423e9..de92afe69a2 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -83,13 +83,14 @@ class QswDataEntity(CoordinatorEntity[QswDataCoordinator]): return value -@dataclass +@dataclass(frozen=True) class QswEntityDescriptionMixin: """Mixin to describe a QSW entity.""" subkey: str +@dataclass(frozen=True) class QswEntityDescription(EntityDescription, QswEntityDescriptionMixin): """Class to describe a QSW entity.""" diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index 0c287c66073..3168e4511d2 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -50,7 +50,7 @@ from .coordinator import QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): """A class that describes QNAP QSW sensor entities.""" diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index f36516ac05b..ad9dd4e1ae0 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -47,14 +47,14 @@ def get_modified_description( return desc, name -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int | datetime] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescription( SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 7f93db67c4c..f0cbfd636fa 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -32,7 +32,7 @@ TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -@dataclass +@dataclass(frozen=True) class RainMachineBinarySensorDescription( BinarySensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 82829094957..a13d2069007 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -24,14 +24,14 @@ from .const import DATA_PROVISION_SETTINGS, DOMAIN from .model import RainMachineEntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescriptionMixin: """Define an entity description mixin for RainMachine buttons.""" push_action: Callable[[Controller], Awaitable] -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescription( ButtonEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index 9ae99fe247a..e45448c0fe4 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -4,28 +4,28 @@ from dataclasses import dataclass from homeassistant.helpers.entity import EntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinApiCategory: """Define an entity description mixin to include an API category.""" api_category: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinDataKey: """Define an entity description mixin to include a data payload key.""" data_key: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinUid: """Define an entity description mixin to include an activity UID.""" uid: int -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescription( EntityDescription, RainMachineEntityDescriptionMixinApiCategory ): diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 2a5bc93f601..513c02ddc19 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -22,7 +22,7 @@ from .model import ( from .util import key_exists -@dataclass +@dataclass(frozen=True) class RainMachineSelectDescription( SelectEntityDescription, RainMachineEntityDescription, @@ -40,14 +40,14 @@ class FreezeProtectionSelectOption: metric_label: str -@dataclass +@dataclass(frozen=True) class FreezeProtectionTemperatureMixin: """Define an entity description mixin to include an options list.""" extended_options: list[FreezeProtectionSelectOption] -@dataclass +@dataclass(frozen=True) class FreezeProtectionSelectDescription( RainMachineSelectDescription, FreezeProtectionTemperatureMixin ): diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index bdae62c1bd8..624deeb46c6 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -48,7 +48,7 @@ TYPE_RAIN_SENSOR_RAIN_START = "rain_sensor_rain_start" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" -@dataclass +@dataclass(frozen=True) class RainMachineSensorDataDescription( SensorEntityDescription, RainMachineEntityDescription, @@ -57,7 +57,7 @@ class RainMachineSensorDataDescription( """Describe a RainMachine sensor.""" -@dataclass +@dataclass(frozen=True) class RainMachineSensorCompletionTimerDescription( SensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index e6ed92d04dc..361f8b2583b 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -133,7 +133,7 @@ def raise_on_request_error( return decorator -@dataclass +@dataclass(frozen=True) class RainMachineSwitchDescription( SwitchEntityDescription, RainMachineEntityDescription, @@ -141,14 +141,14 @@ class RainMachineSwitchDescription( """Describe a RainMachine switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineActivitySwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinUid ): """Describe a RainMachine activity (program/zone) switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineRestrictionSwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinDataKey ): diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 96311266db4..ce8e2908251 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes RDW binary sensor entity.""" diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index d25c23c09bd..a6ad9047852 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import ( from .const import CONF_LICENSE_PLATE, DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RDWSensorEntityDescription(SensorEntityDescription): """Describes RDW sensor entity.""" diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index ef2d7196f04..0d66e5444e7 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -22,7 +22,7 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class RenaultBinarySensorRequiredKeysMixin: on_value: StateType -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorEntityDescription( BinarySensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 5f916a2d140..87883204890 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -15,14 +15,14 @@ from .entity import RenaultEntity from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[RenaultButtonEntity], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class RenaultButtonEntityDescription( ButtonEntityDescription, RenaultButtonRequiredKeysMixin ): diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index aa83c935957..fd7f0eb3654 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -12,14 +12,14 @@ from .coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultDataRequiredKeysMixin: """Mixin for required keys.""" coordinator: str -@dataclass +@dataclass(frozen=True) class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): """Class describing Renault data entities.""" diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 1ec891a51e4..9dcc52abc87 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -18,7 +18,7 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultSelectRequiredKeysMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class RenaultSelectRequiredKeysMixin: icon_lambda: Callable[[RenaultSelectEntity], str] -@dataclass +@dataclass(frozen=True) class RenaultSelectEntityDescription( SelectEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 92deb3438de..d30b8d01fb3 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -43,7 +43,7 @@ from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultSensorRequiredKeysMixin(Generic[T]): """Mixin for required keys.""" @@ -51,7 +51,7 @@ class RenaultSensorRequiredKeysMixin(Generic[T]): entity_class: type[RenaultSensor[T]] -@dataclass +@dataclass(frozen=True) class RenaultSensorEntityDescription( SensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 39c2b1b883d..012ecee2e98 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -30,14 +30,14 @@ from .coordinator import RensonCoordinator from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" field: FieldEnum -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescription( BinarySensorEntityDescription, RensonBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index a91a057e0e7..117fadb502b 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -21,14 +21,14 @@ from .const import DOMAIN from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescriptionMixin: """Action function called on press.""" action_fn: Callable[[RensonVentilation], None] -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescription( ButtonEntityDescription, RensonButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 004be661f02..380a83b6818 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -52,7 +52,7 @@ from .coordinator import RensonCoordinator from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ class RensonSensorEntityDescriptionMixin: raw_format: bool -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescription( SensorEntityDescription, RensonSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 226b81b1c74..03b30d8195e 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -28,7 +28,7 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkBinarySensorEntityDescription( BinarySensorEntityDescription, ReolinkChannelEntityDescription, diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 88204d9a806..5656f178db6 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -38,7 +38,7 @@ ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkButtonEntityDescription( ButtonEntityDescription, ReolinkChannelEntityDescription, @@ -50,7 +50,7 @@ class ReolinkButtonEntityDescription( ptz_cmd: str | None = None -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkHostButtonEntityDescription( ButtonEntityDescription, ReolinkHostEntityDescription, diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 2ad8105c66c..715588a8225 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -24,7 +24,7 @@ from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescrip _LOGGER = logging.getLogger(__name__) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkCameraEntityDescription( CameraEntityDescription, ReolinkChannelEntityDescription, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 8da64991c27..042e6b45717 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -20,7 +20,7 @@ from .const import DOMAIN _T = TypeVar("_T") -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkChannelEntityDescription(EntityDescription): """A class that describes entities for a camera channel.""" @@ -28,7 +28,7 @@ class ReolinkChannelEntityDescription(EntityDescription): supported: Callable[[Host, int], bool] = lambda api, ch: True -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkHostEntityDescription(EntityDescription): """A class that describes host entities.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index bc739343c46..222ab984e3f 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -25,7 +25,7 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkLightEntityDescription( LightEntityDescription, ReolinkChannelEntityDescription, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index aaf549453ed..09869b06e96 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -24,7 +24,7 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkNumberEntityDescription( NumberEntityDescription, ReolinkChannelEntityDescription, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index eb2ea58cc40..769ccdf7e01 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -29,7 +29,7 @@ from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescrip _LOGGER = logging.getLogger(__name__) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkSelectEntityDescription( SelectEntityDescription, ReolinkChannelEntityDescription, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 5eef880fc91..6f4af489fe5 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -29,7 +29,7 @@ from .entity import ( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkSensorEntityDescription( SensorEntityDescription, ReolinkChannelEntityDescription, @@ -39,7 +39,7 @@ class ReolinkSensorEntityDescription( value: Callable[[Host, int], int] -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkHostSensorEntityDescription( SensorEntityDescription, ReolinkHostEntityDescription, diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index ec709f6ae3d..90590acb4e4 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -23,7 +23,7 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass +@dataclass(frozen=True) class ReolinkSirenEntityDescription( SirenEntityDescription, ReolinkChannelEntityDescription ): diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 2ec3149dc8d..7f57b78df1e 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -25,7 +25,7 @@ from .entity import ( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkSwitchEntityDescription( SwitchEntityDescription, ReolinkChannelEntityDescription, @@ -36,7 +36,7 @@ class ReolinkSwitchEntityDescription( value: Callable[[Host, int], bool] -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkNVRSwitchEntityDescription( SwitchEntityDescription, ReolinkHostEntityDescription, diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 6a0df99ce1d..91a16ea3fbe 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -124,14 +124,14 @@ def has_all_unique_names(value): return value -@dataclass +@dataclass(frozen=True) class RepetierRequiredKeysMixin: """Mixin for required keys.""" type: str -@dataclass +@dataclass(frozen=True) class RepetierSensorEntityDescription( SensorEntityDescription, RepetierRequiredKeysMixin ): diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 60f35a93d1a..66803edffc5 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -58,7 +58,7 @@ def _rssi_convert(value: int | None) -> str | None: return f"{value*8-120}" -@dataclass +@dataclass(frozen=True) class RfxtrxSensorEntityDescription(SensorEntityDescription): """Description of sensor entities.""" diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 05d26812f54..27eb82d34ee 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -18,14 +18,14 @@ from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATO from .entity import RingEntityMixin -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" category: list[str] -@dataclass +@dataclass(frozen=True) class RingBinarySensorEntityDescription( BinarySensorEntityDescription, RingRequiredKeysMixin ): diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 465f6196689..a596d413ac7 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -173,7 +173,7 @@ class HistoryRingSensor(RingSensor): return attrs -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" @@ -181,7 +181,7 @@ class RingRequiredKeysMixin: cls: type[RingSensor] -@dataclass +@dataclass(frozen=True) class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin): """Describes Ring sensor entity.""" diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index ab13898394c..f33a687b88f 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -21,7 +21,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Rituals binary sensor entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 35b5a3bd008..164b6de52c9 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsNumberEntityDescription(NumberEntityDescription): """Class describing Rituals number entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 2126ecb147f..b9f0c29b267 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsSelectEntityDescription(SelectEntityDescription): """Class describing Rituals select entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 5f7ae45d330..cd139c94f1c 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -21,7 +21,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsSensorEntityDescription(SensorEntityDescription): """Class describing Rituals sensor entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 77776704a60..9c9a5f73d16 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass +@dataclass(frozen=True) class RitualsEntityDescriptionMixin: """Mixin values for Rituals entities.""" @@ -26,7 +26,7 @@ class RitualsEntityDescriptionMixin: turn_off_fn: Callable[[Diffuser], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class RitualsSwitchEntityDescription( SwitchEntityDescription, RitualsEntityDescriptionMixin ): diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 203f981e51d..03e1eabe45a 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -22,14 +22,14 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescriptionMixin: """A class that describes binary sensor entities.""" value_fn: Callable[[DeviceProp], bool | int | None] -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescription( BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index aba86ccb6b6..7744c5988d8 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -17,7 +17,7 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockEntity -@dataclass +@dataclass(frozen=True) class RoborockButtonDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -25,7 +25,7 @@ class RoborockButtonDescriptionMixin: param: list | dict | None -@dataclass +@dataclass(frozen=True) class RoborockButtonDescription( ButtonEntityDescription, RoborockButtonDescriptionMixin ): diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index d91606418d9..8957c487a64 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -23,7 +23,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockNumberDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -33,7 +33,7 @@ class RoborockNumberDescriptionMixin: update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, dict]] -@dataclass +@dataclass(frozen=True) class RoborockNumberDescription( NumberEntityDescription, RoborockNumberDescriptionMixin ): diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 1a05f3ec9c1..ae5dd12689d 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -18,7 +18,7 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -32,7 +32,7 @@ class RoborockSelectDescriptionMixin: parameter_lambda: Callable[[str, Status], list[int]] -@dataclass +@dataclass(frozen=True) class RoborockSelectDescription( SelectEntityDescription, RoborockSelectDescriptionMixin ): diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 775fc0cfb5f..e3cea00476f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -36,14 +36,14 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" value_fn: Callable[[DeviceProp], StateType | datetime.datetime] -@dataclass +@dataclass(frozen=True) class RoborockSensorDescription( SensorEntityDescription, RoborockSensorDescriptionMixin ): diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 3dd7307da72..37e8488dd22 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,7 +24,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -36,7 +36,7 @@ class RoborockSwitchDescriptionMixin: attribute: str -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescription( SwitchEntityDescription, RoborockSwitchDescriptionMixin ): diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index d02d63597ac..7a8d21fc0f1 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -25,7 +25,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockTimeDescriptionMixin: """Define an entity description mixin for time entities.""" @@ -37,7 +37,7 @@ class RoborockTimeDescriptionMixin: get_value: Callable[[AttributeCache], datetime.time] -@dataclass +@dataclass(frozen=True) class RoborockTimeDescription(TimeEntityDescription, RoborockTimeDescriptionMixin): """Class to describe an Roborock time entity.""" diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index b08933dcd91..144fded24b9 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], bool | None] -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescription( BinarySensorEntityDescription, RokuBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 430133b7f77..ef0f198f586 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -18,7 +18,7 @@ from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescriptionMixin: """Mixin for required keys.""" @@ -85,7 +85,7 @@ async def _tune_channel(device: RokuDevice, roku: Roku, value: str) -> None: await roku.tune(_channel.number) -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescription( SelectEntityDescription, RokuSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 69b8c34d312..b462b8c531b 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -17,14 +17,14 @@ from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], str | None] -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescription( SensorEntityDescription, RokuSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 7d103111301..09d4d643be9 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -21,14 +21,14 @@ from .irobot_base import IRobotEntity from .models import RoombaData -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescriptionMixin: """Mixin for describing Roomba data.""" value_fn: Callable[[IRobotEntity], StateType] -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescription( SensorEntityDescription, RoombaSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index d4920ef77f3..ff33c084ffa 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -20,14 +20,14 @@ from . import DOMAIN, SIGNAL_SABNZBD_UPDATED from .const import DEFAULT_NAME, KEY_API_DATA -@dataclass +@dataclass(frozen=True) class SabnzbdRequiredKeysMixin: """Mixin for required keys.""" key: str -@dataclass +@dataclass(frozen=True) class SabnzbdSensorEntityDescription(SensorEntityDescription, SabnzbdRequiredKeysMixin): """Describes Sabnzbd sensor entity.""" diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index 749a961a53b..5c97a903c72 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -20,7 +20,7 @@ from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class SchlageBinarySensorEntityDescriptionMixin: value_fn: Callable[[LockData], bool] -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescription( BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 1a4eeb7bcc7..36c8fa74244 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -24,7 +24,7 @@ from .coordinator import SchlageDataUpdateCoordinator from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class SchlageSwitchEntityDescriptionMixin: value_fn: Callable[[Lock], bool] -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescription( SwitchEntityDescription, SchlageSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index eb808835c58..cb73fab90ee 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -32,14 +32,14 @@ from .util import cleanup_excluded_entity _LOGGER = logging.getLogger(__name__) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicBinarySensorDescription( BinarySensorEntityDescription, ScreenLogicEntityDescription ): """A class that describes ScreenLogic binary sensor eneites.""" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushBinarySensorDescription( ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index d78c2c16e48..1e9a90395f4 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -68,7 +68,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicClimateDescription( ClimateEntityDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 253d16610e4..06551c2736b 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -28,14 +28,14 @@ from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityRequiredKeyMixin: """Mixin for required ScreenLogic entity data_path.""" data_root: ScreenLogicDataPath -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityDescription( EntityDescription, ScreenLogicEntityRequiredKeyMixin ): @@ -103,14 +103,14 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): raise HomeAssistantError(f"Data not found: {self._data_path}") from ke -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityRequiredKeyMixin: """Mixin for required key for ScreenLogic push entities.""" subscription_code: CODE -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityDescription( ScreenLogicEntityDescription, ScreenLogicPushEntityRequiredKeyMixin, diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 80499f7790a..60cf7d52a48 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -60,7 +60,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicLightDescription( LightEntityDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 091d377a56b..a275705f646 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -29,14 +29,14 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str -@dataclass +@dataclass(frozen=True) class ScreenLogicNumberDescription( NumberEntityDescription, ScreenLogicEntityDescription, diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 5cb4e5acfe9..87bc101a074 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -35,21 +35,21 @@ from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorMixin: """Mixin for SecreenLogic sensor entity.""" value_mod: Callable[[int | str], int | str] | None = None -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorDescription( ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription ): """Describes a ScreenLogic sensor.""" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushSensorDescription( ScreenLogicSensorDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 4900ed938a1..e64f7a3a164 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -56,7 +56,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicSwitchDescription( SwitchEntityDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 08f45b94789..5cd71a2b0e4 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -24,28 +24,28 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], bool | None] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] -@dataclass +@dataclass(frozen=True) class SensiboMotionBinarySensorEntityDescription( BinarySensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceBinarySensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index b47023f3ec4..942f7eaeb00 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -17,14 +17,14 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" data_key: str -@dataclass +@dataclass(frozen=True) class SensiboButtonEntityDescription( ButtonEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index d4e268ea44d..ac76277fb20 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -24,7 +24,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -32,7 +32,7 @@ class SensiboEntityDescriptionMixin: value_fn: Callable[[SensiboDevice], float | None] -@dataclass +@dataclass(frozen=True) class SensiboNumberEntityDescription( NumberEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 3e13c6cec70..bbac3fbdbd0 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -20,7 +20,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboSelectDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -30,7 +30,7 @@ class SensiboSelectDescriptionMixin: transformation: Callable[[SensiboDevice], dict | None] -@dataclass +@dataclass(frozen=True) class SensiboSelectEntityDescription( SelectEntityDescription, SensiboSelectDescriptionMixin ): diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index f6d62d79dff..805b888204b 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,14 +36,14 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], StateType] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -51,14 +51,14 @@ class DeviceBaseEntityDescriptionMixin: extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None -@dataclass +@dataclass(frozen=True) class SensiboMotionSensorEntityDescription( SensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index a27307fcceb..0911985ed7d 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -24,7 +24,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo Device description keys.""" @@ -35,7 +35,7 @@ class DeviceBaseEntityDescriptionMixin: data_key: str -@dataclass +@dataclass(frozen=True) class SensiboDeviceSwitchEntityDescription( SwitchEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 62e8bbff3ae..c51d57dd9d1 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -23,7 +23,7 @@ from .entity import SensiboDeviceBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -31,7 +31,7 @@ class DeviceBaseEntityDescriptionMixin: value_available: Callable[[SensiboDevice], str | None] -@dataclass +@dataclass(frozen=True) class SensiboDeviceUpdateEntityDescription( UpdateEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 79533576efb..9bf053a3897 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -26,14 +26,14 @@ from .models import DomainData _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], bool | None] -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorEntityDescription( BinarySensorEntityDescription, SFRBoxBinarySensorMixin[_T] ): diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index c9418bcc2e9..80f7d6d51e4 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -49,14 +49,14 @@ def with_error_wrapping( return wrapper -@dataclass +@dataclass(frozen=True) class SFRBoxButtonMixin: """Mixin for SFR Box buttons.""" async_press: Callable[[SFRBox], Coroutine[None, None, None]] -@dataclass +@dataclass(frozen=True) class SFRBoxButtonEntityDescription(ButtonEntityDescription, SFRBoxButtonMixin): """Description for SFR Box buttons.""" diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index f56a9765618..6f77ca8d285 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -32,14 +32,14 @@ from .models import DomainData _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxSensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], StateType] -@dataclass +@dataclass(frozen=True) class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_T]): """Description for SFR Box sensors.""" diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index caed52279da..b07747f298e 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -39,19 +39,19 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class BlockBinarySensorDescription( BlockEntityDescription, BinarySensorEntityDescription ): """Class to describe a BLOCK binary sensor.""" -@dataclass +@dataclass(frozen=True) class RpcBinarySensorDescription(RpcEntityDescription, BinarySensorEntityDescription): """Class to describe a RPC binary sensor.""" -@dataclass +@dataclass(frozen=True) class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescription): """Class to describe a REST binary sensor.""" diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index e5cc6b6580b..17f60f566aa 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -30,14 +30,14 @@ _ShellyCoordinatorT = TypeVar( ) -@dataclass +@dataclass(frozen=True) class ShellyButtonDescriptionMixin(Generic[_ShellyCoordinatorT]): """Mixin to describe a Button entity.""" press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class ShellyButtonDescription( ButtonEntityDescription, ShellyButtonDescriptionMixin[_ShellyCoordinatorT] ): diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 368a997c62e..796402c8bba 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -266,7 +266,7 @@ def async_setup_entry_rest( ) -@dataclass +@dataclass(frozen=True) class BlockEntityDescription(EntityDescription): """Class to describe a BLOCK entity.""" @@ -283,14 +283,14 @@ class BlockEntityDescription(EntityDescription): extra_state_attributes: Callable[[Block], dict | None] | None = None -@dataclass +@dataclass(frozen=True) class RpcEntityRequiredKeysMixin: """Class for RPC entity required keys.""" sub_key: str -@dataclass +@dataclass(frozen=True) class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): """Class to describe a RPC entity.""" @@ -306,7 +306,7 @@ class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): supported: Callable = lambda _: False -@dataclass +@dataclass(frozen=True) class RestEntityDescription(EntityDescription): """Class to describe a REST entity.""" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index e93303d7191..5425f71366f 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -37,14 +37,14 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class ShellyBlockEventDescription(EventEntityDescription): """Class to describe Shelly event.""" removal_condition: Callable[[dict, Block], bool] | None = None -@dataclass +@dataclass(frozen=True) class ShellyRpcEventDescription(EventEntityDescription): """Class to describe Shelly event.""" diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index f2b6bedb443..77d066a6106 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,7 @@ from .entity import ( ) -@dataclass +@dataclass(frozen=True) class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 4518135214c..fa5f51b00b0 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -55,17 +55,17 @@ from .entity import ( from .utils import get_device_entry_gen, get_device_uptime -@dataclass +@dataclass(frozen=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): """Class to describe a BLOCK sensor.""" -@dataclass +@dataclass(frozen=True) class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" -@dataclass +@dataclass(frozen=True) class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5ef39cd33af..98811c2ff6f 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -30,7 +30,7 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 975b61e631a..9e8b1505afe 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -40,7 +40,7 @@ from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RpcUpdateRequiredKeysMixin: """Class for RPC update required keys.""" @@ -48,7 +48,7 @@ class RpcUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RestUpdateRequiredKeysMixin: """Class for REST update required keys.""" @@ -56,14 +56,14 @@ class RestUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RpcUpdateDescription( RpcEntityDescription, UpdateEntityDescription, RpcUpdateRequiredKeysMixin ): """Class to describe a RPC update.""" -@dataclass +@dataclass(frozen=True) class RestUpdateDescription( RestEntityDescription, UpdateEntityDescription, RestUpdateRequiredKeysMixin ): diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 149a0427ed0..e7850a5f9d2 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -29,7 +29,7 @@ from .sia_entity_base import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIAAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index db0845473fd..f6e2533be93 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -32,7 +32,7 @@ from .sia_entity_base import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIABinarySensorEntityDescription( BinarySensorEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index a947f9e177b..f6895cc48a9 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -35,14 +35,14 @@ from .utils import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIARequiredKeysMixin: """Required keys for SIA entities.""" code_consequences: dict[str, StateType | bool] -@dataclass +@dataclass(frozen=True) class SIAEntityDescription(EntityDescription, SIARequiredKeysMixin): """Entity Description for SIA entities.""" diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index bd60c040f56..a11ddc04d64 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .typing import SystemType -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescriptionMixin: """Define an entity description mixin for SimpliSafe buttons.""" push_action: Callable[[System], Awaitable] -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescription( ButtonEntityDescription, SimpliSafeButtonDescriptionMixin ): diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 130196a990d..7093c5cad20 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -22,14 +22,14 @@ from homeassistant.helpers.typing import StateType from .entity import DOMAIN, SkybellEntity -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescriptionMixIn: """Mixin for Skybell sensor.""" value_fn: Callable[[SkybellDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescription( SensorEntityDescription, SkybellSensorEntityDescriptionMixIn ): diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index cca9253d589..0d9a118d3c9 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -17,14 +17,14 @@ from .coordinator import SleepIQData from .entity import SleepIQEntity -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescriptionMixin: """Describes a SleepIQ Button entity.""" press_action: Callable[[SleepIQBed], Any] -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescription( ButtonEntityDescription, SleepIQButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 5523f931bd4..b1819d7088d 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -17,7 +17,7 @@ from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescriptionMixin: """Mixin to describe a SleepIQ number entity.""" @@ -27,7 +27,7 @@ class SleepIQNumberEntityDescriptionMixin: get_unique_id_fn: Callable[[SleepIQBed, Any], str] -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescription( NumberEntityDescription, SleepIQNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 82bc60936b3..ad6e5af963e 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -18,26 +18,26 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SmappeeRequiredKeysMixin: """Mixin for required keys.""" sensor_id: str -@dataclass +@dataclass(frozen=True) class SmappeeSensorEntityDescription(SensorEntityDescription, SmappeeRequiredKeysMixin): """Describes Smappee sensor entity.""" -@dataclass +@dataclass(frozen=True) class SmappeePollingSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" local_polling: bool = False -@dataclass +@dataclass(frozen=True) class SmappeeVoltageSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 5e298ae2a6f..bb82da5fc89 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -33,14 +33,14 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityRequiredKeyMixin: """Sensor entity description with json_key for SolarEdge.""" json_key: str -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityDescription( SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin ): diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index f8e0595f28f..0475489a6f4 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -50,7 +50,7 @@ INVERTER_MODES = ( ) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): """Describes SolarEdge-local sensor entity.""" diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index cd8304a1198..a8025c7fc0f 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -26,7 +26,7 @@ from . import SolarlogData from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index def44d382ce..5753d0d23ea 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -31,7 +31,7 @@ from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator from .entity import SonarrEntity -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): """Mixin for Sonarr sensor.""" @@ -39,7 +39,7 @@ class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): value_fn: Callable[[SonarrDataT], StateType] -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescription( SensorEntityDescription, SonarrSensorEntityDescriptionMixIn[SonarrDataT] ): diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index af41c400e0b..53e80be0cc0 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -33,7 +33,7 @@ from .const import ( from .coordinator import SpeedTestDataCoordinator -@dataclass +@dataclass(frozen=True) class SpeedtestSensorEntityDescription(SensorEntityDescription): """Class describing Speedtest sensor entities.""" diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index b4a1561dd26..600dac34fe3 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -15,7 +15,7 @@ from .const import DOMAIN from .entity import StarlineEntity -@dataclass +@dataclass(frozen=True) class StarlineRequiredKeysMixin: """Mixin for required keys.""" @@ -23,7 +23,7 @@ class StarlineRequiredKeysMixin: icon_off: str -@dataclass +@dataclass(frozen=True) class StarlineSwitchEntityDescription( SwitchEntityDescription, StarlineRequiredKeysMixin ): diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index 87614460096..d346c19fec4 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -32,14 +32,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], bool | None] -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescription( BinarySensorEntityDescription, StarlinkBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index 2df9d9b033b..daf3122a00d 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -31,14 +31,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescriptionMixin: """Mixin for required keys.""" press_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescription( ButtonEntityDescription, StarlinkButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index eb832741f40..f260a7d1c32 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -26,7 +26,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescriptionMixin: """Describes a Starlink device tracker.""" @@ -34,7 +34,7 @@ class StarlinkDeviceTrackerEntityDescriptionMixin: longitude_fn: Callable[[StarlinkData], float] -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescription( EntityDescription, StarlinkDeviceTrackerEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index ab76a8dffdd..d5116d49305 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -40,14 +40,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], datetime | StateType] -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescription( SensorEntityDescription, StarlinkSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 31932fe9854..551afa8e73c 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class StarlinkSwitchEntityDescriptionMixin: turn_off_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescription( SwitchEntityDescription, StarlinkSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 17cc0e8c272..beb8eea47b9 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -30,14 +30,14 @@ UNIT_MAPPINGS = { } -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[SteamistStatus], int | None] -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescription( SensorEntityDescription, SteamistSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 0f867f9b7c4..437f1626c9d 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" -@dataclass +@dataclass(frozen=True) class SunEntityDescriptionMixin: """Mixin for required Sun base description keys.""" @@ -34,7 +34,7 @@ class SunEntityDescriptionMixin: signal: str -@dataclass +@dataclass(frozen=True) class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): """Describes Sun sensor entity.""" diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py index d9a48a331b1..a47818b694b 100644 --- a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -6,14 +6,14 @@ from dataclasses import dataclass from homeassistant.components.sensor import SensorEntityDescription -@dataclass +@dataclass(frozen=True) class SunWEGRequiredKeysMixin: """Mixin for required keys.""" api_variable_key: str -@dataclass +@dataclass(frozen=True) class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): """Describes SunWEG sensor entity.""" diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 4303c885106..5a1b7c821d2 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -30,7 +30,7 @@ from .const import SIGNAL_DEVICE_ADD from .utils import get_breeze_remote_manager -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonDescriptionMixin: """Mixin to describe a Switcher Thermostat Button entity.""" @@ -38,7 +38,7 @@ class SwitcherThermostatButtonDescriptionMixin: supported: Callable[[SwitcherBreezeRemote], bool] -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonEntityDescription( ButtonEntityDescription, SwitcherThermostatButtonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 1f335aee4b9..27c6b416cb4 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -27,7 +27,7 @@ from .entity import ( from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMBinarySensorEntityDescription( BinarySensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index d62f816b29e..0e737c48eb6 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -24,14 +24,14 @@ from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescriptionMixin: """Mixin to describe a Synology DSM button entity.""" press_action: Callable[[SynoApi], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescription( ButtonEntityDescription, SynologyDSMbuttonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index a2f08202319..187db9fbba8 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -35,7 +35,7 @@ from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMCameraEntityDescription( CameraEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index bb668e292cc..8d53284fee7 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -18,14 +18,14 @@ from .coordinator import ( _CoordinatorT = TypeVar("_CoordinatorT", bound=SynologyDSMUpdateCoordinator[Any]) -@dataclass +@dataclass(frozen=True) class SynologyDSMRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class SynologyDSMEntityDescription(EntityDescription, SynologyDSMRequiredKeysMixin): """Generic Synology DSM entity description.""" diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 29298647326..76606303c93 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -39,7 +39,7 @@ from .entity import ( from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMSensorEntityDescription( SensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 074a423c53d..77dc854fa3a 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -22,7 +22,7 @@ from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMSwitchEntityDescription( SwitchEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index c550b180553..c66fc3c3d73 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -19,7 +19,7 @@ from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMUpdateEntityEntityDescription( UpdateEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 511feeaf93c..1d36c673eb6 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -19,7 +19,7 @@ from .coordinator import SystemBridgeDataUpdateCoordinator from .entity import SystemBridgeEntity -@dataclass +@dataclass(frozen=True) class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing System Bridge binary sensor entities.""" diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e3fd2c14654..35cc0e00809 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -42,7 +42,7 @@ ATTR_USED: Final = "used" PIXELS: Final = "px" -@dataclass +@dataclass(frozen=True) class SystemBridgeSensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 4cfbdba4066..f34686ca3da 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -64,7 +64,7 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -@dataclass +@dataclass(frozen=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index c5222112c02..0f7a1b2b307 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -32,14 +32,14 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], bool] -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescription( BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index c665cc3c592..a9647c7e6e5 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -35,14 +35,14 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], StateType] -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescription( SensorEntityDescription, TadoSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index ee1c682c559..00fa21279ea 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -20,7 +20,7 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tailscale binary sensor entity.""" diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index f5850848c8c..5d2e615945b 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -21,7 +21,7 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailscaleSensorEntityDescription(SensorEntityDescription): """Describes a Tailscale sensor entity.""" diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index d860f7de3d6..e66a95f3ac4 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -22,7 +22,7 @@ from .coordinator import TailwindDataUpdateCoordinator from .entity import TailwindEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailwindButtonEntityDescription(ButtonEntityDescription): """Class describing Tailwind button entities.""" diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 19d23457121..3d932939ba4 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -18,7 +18,7 @@ from .coordinator import TailwindDataUpdateCoordinator from .entity import TailwindEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailwindNumberEntityDescription(NumberEntityDescription): """Class describing Tailwind number entities.""" diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index a64f4312de1..ca9de9df8de 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -43,14 +43,14 @@ def get_top_stats( return value -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityMixin: """Mixin for Tautulli sensor.""" value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityDescription( SensorEntityDescription, TautulliSensorEntityMixin ): @@ -151,14 +151,14 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( ) -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityMixin: """Mixin for Tautulli session sensor.""" value_fn: Callable[[PyTautulliApiSession], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityDescription( SensorEntityDescription, TautulliSessionSensorEntityMixin ): diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 41403ab84f2..30e61dc7744 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -152,7 +152,7 @@ class WallConnectorEntity(CoordinatorEntity): ) -@dataclass() +@dataclass(frozen=True) class WallConnectorLambdaValueGetterMixin: """Mixin with a function pointer for getting sensor value.""" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index e0a34460c8c..e9ac03c69e1 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -22,7 +22,7 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorBinarySensorDescription( BinarySensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 0322830890a..1b9433eb696 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -30,7 +30,7 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITAL _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorSensorDescription( SensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 297a59cac6d..95cf789b694 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -19,7 +19,7 @@ from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity -@dataclass +@dataclass(frozen=True) class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Tessie binary sensor entity.""" diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 6f79d986998..6c39d2a8e77 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -32,7 +32,7 @@ from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity -@dataclass +@dataclass(frozen=True) class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 3e07392c336..b31b5102394 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -18,7 +18,7 @@ from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator from .const import DOMAIN, FAN_TIMER_MAX, POWER_TIMER_MAX, SALT_BATH_TIMER_MAX -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescriptionBase: """Required values when describing TOLO Number entities.""" @@ -26,7 +26,7 @@ class ToloNumberEntityDescriptionBase: setter: Callable[[ToloClient, int | None], Any] -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescription( NumberEntityDescription, ToloNumberEntityDescriptionBase ): diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 2ff901939ae..ec57612a99f 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -26,7 +26,7 @@ from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescriptionBase: """Required values when describing TOLO Sensor entities.""" @@ -34,7 +34,7 @@ class ToloSensorEntityDescriptionBase: availability_checker: Callable[[SettingsInfo, StatusInfo], bool] | None -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescription( SensorEntityDescription, ToloSensorEntityDescriptionBase ): diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 88b5af79604..6b285378e7e 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -70,7 +70,7 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index e632915edf7..6edc656df06 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -91,14 +91,14 @@ class ToonBoilerModuleBinarySensor(ToonBinarySensor, ToonBoilerModuleDeviceEntit """Defines a Boiler module binary sensor.""" -@dataclass +@dataclass(frozen=True) class ToonBinarySensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for binary sensor required keys.""" cls: type[ToonBinarySensor] -@dataclass +@dataclass(frozen=True) class ToonBinarySensorEntityDescription( BinarySensorEntityDescription, ToonBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 75e3ddb0370..44986b02143 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -151,7 +151,7 @@ class ToonBoilerDeviceEntity(ToonEntity): ) -@dataclass +@dataclass(frozen=True) class ToonRequiredKeysMixin: """Mixin for required keys.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 90dd466045c..7ff9d2b67f7 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -114,14 +114,14 @@ class ToonDisplayDeviceSensor(ToonSensor, ToonDisplayDeviceEntity): """Defines a Display sensor.""" -@dataclass +@dataclass(frozen=True) class ToonSensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for sensor required keys.""" cls: type[ToonSensor] -@dataclass +@dataclass(frozen=True) class ToonSensorEntityDescription(SensorEntityDescription, ToonSensorRequiredKeysMixin): """Describes Toon sensor entity.""" diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index bf283b203c7..8dddb657df0 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -94,14 +94,14 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): ) -@dataclass +@dataclass(frozen=True) class ToonSwitchRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for switch required keys.""" cls: type[ToonSwitch] -@dataclass +@dataclass(frozen=True) class ToonSwitchEntityDescription(SwitchEntityDescription, ToonSwitchRequiredKeysMixin): """Describes Toon switch entity.""" diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 46909f39dfe..4fd957c2d8f 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -35,7 +35,7 @@ from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity -@dataclass +@dataclass(frozen=True) class TPLinkSensorEntityDescription(SensorEntityDescription): """Describes TPLink sensor entity.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 49eda4f8d09..ab9dad88e06 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -43,14 +43,14 @@ from .const import ( from .entity import TractiveEntity -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" signal_prefix: str -@dataclass +@dataclass(frozen=True) class TractiveSensorEntityDescription( SensorEntityDescription, TractiveRequiredKeysMixin ): diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 58c82bd6514..b77c35e6904 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -28,14 +28,14 @@ from .entity import TractiveEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" method: Literal["async_set_buzzer", "async_set_led", "async_set_live_tracking"] -@dataclass +@dataclass(frozen=True) class TractiveSwitchEntityDescription( SwitchEntityDescription, TractiveRequiredKeysMixin ): diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 383eec8a8fb..7f04b8aff03 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -37,14 +37,14 @@ from .const import ( from .coordinator import TradfriDeviceDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescriptionMixin: """Mixin for required keys.""" value: Callable[[Device], Any | None] -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescription( SensorEntityDescription, TradfriSensorEntityDescriptionMixin, diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index c9da5bd5d8a..b725f6d2f95 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -19,14 +19,14 @@ from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], bool | None] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index 96231bba755..678c703307c 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -23,14 +23,14 @@ from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index a673f624a47..cd0682c12bc 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -32,7 +32,7 @@ ICON = "mdi:ferry" SCAN_INTERVAL = timedelta(minutes=5) -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class TrafikverketRequiredKeysMixin: info_fn: Callable[[dict[str, Any]], StateType | list] | None -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index a5e76299b61..3aff376ab27 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -25,14 +25,14 @@ from .coordinator import TrainData, TVDataUpdateCoordinator ATTR_PRODUCT_FILTER = "product_filter" -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrainData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 607a230fbbe..9c025237187 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -42,14 +42,14 @@ PRECIPITATION_TYPE = [ ] -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[WeatherStationInfo], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 29d37f28bad..87bcb87da9a 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -43,7 +43,7 @@ MODES: dict[str, list[str] | None] = { } -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TransmissionSensorEntityDescription(SensorEntityDescription): """Entity description class for Transmission sensors.""" diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index fecda94fbf8..643b2f0ba70 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -17,7 +17,7 @@ from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class TransmissionSwitchEntityDescriptionMixin: off_func: Callable[[TransmissionDataUpdateCoordinator], None] -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescription( SwitchEntityDescription, TransmissionSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index c57a37365ed..8e934ae6593 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -21,7 +21,7 @@ from .base import TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode -@dataclass +@dataclass(frozen=True) class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 6b3b84ba349..b8c66c5cc35 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -39,14 +39,14 @@ TUYA_HVAC_TO_HA = { } -@dataclass +@dataclass(frozen=True) class TuyaClimateSensorDescriptionMixin: """Define an entity description mixin for climate entities.""" switch_only_hvac_mode: HVACMode -@dataclass +@dataclass(frozen=True) class TuyaClimateEntityDescription( ClimateEntityDescription, TuyaClimateSensorDescriptionMixin ): diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index da9f7d29eb2..46bd0721ccb 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -24,7 +24,7 @@ from .base import IntegerTypeData, TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6d09ba4314c..a8008ced953 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from .base import IntegerTypeData, TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaHumidifierEntityDescription(HumidifierEntityDescription): """Describe an Tuya (de)humidifier entity.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b4396f617cd..8e98e8d6a41 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -49,7 +49,7 @@ DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( ) -@dataclass +@dataclass(frozen=True) class TuyaLightEntityDescription(LightEntityDescription): """Describe an Tuya light entity.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 4bf8808f5f1..62b59cb8ed9 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -38,7 +38,7 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 1278f6523a5..32b4de47de4 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -21,7 +21,7 @@ from .const import DOMAIN from .entity import TwenteMilieuEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TwenteMilieuSensorDescription(SensorEntityDescription): """Describe an Twente Milieu sensor.""" diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index af7ab5852ab..c77a1f01447 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -57,14 +57,14 @@ async def async_power_cycle_port_control_fn( await api.request(DevicePowerCyclePortRequest.create(mac, int(index))) -@dataclass +@dataclass(frozen=True) class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class UnifiButtonEntityDescription( ButtonEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 1be52b97974..88667d8e811 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -137,7 +137,7 @@ def async_device_heartbeat_timedelta_fn( return timedelta(seconds=device.next_interval + 60) -@dataclass +@dataclass(frozen=True) class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" @@ -147,7 +147,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): hostname_fn: Callable[[aiounifi.Controller, str], str | None] -@dataclass +@dataclass(frozen=True) class UnifiTrackerEntityDescription( UnifiEntityDescription[HandlerT, ApiItemT], UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 28a7b557b16..08dda12c11d 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -93,7 +93,7 @@ def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> Dev ) -@dataclass +@dataclass(frozen=True) class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -110,7 +110,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): unique_id_fn: Callable[[UniFiController, str], str] -@dataclass +@dataclass(frozen=True) class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 2318702f0d1..a4fb8d5eb33 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -36,7 +36,7 @@ def async_wlan_qr_code_image_fn(controller: UniFiController, wlan: Wlan) -> byte return controller.api.wlans.generate_wlan_qr_code(wlan) -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -44,7 +44,7 @@ class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): value_fn: Callable[[ApiItemT], str | None] -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescription( ImageEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index cc825ea51af..c7b851a8fbb 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -132,7 +132,7 @@ def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) - return controller.api.devices[obj_id].outlet_ac_power_budget is not None -@dataclass +@dataclass(frozen=True) class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -145,7 +145,7 @@ def async_device_state_value_fn(controller: UniFiController, device: Device) -> return DEVICE_STATES[device.state] -@dataclass +@dataclass(frozen=True) class UnifiSensorEntityDescription( SensorEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 1e9ec8b14c8..371676f4786 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -180,7 +180,7 @@ async def async_wlan_control_fn( await controller.api.request(WlanEnableRequest.create(obj_id, target)) -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -188,7 +188,7 @@ class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): is_on_fn: Callable[[UniFiController, ApiItemT], bool] -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescription( SwitchEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 65b26736cf1..a0d2da328a2 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -40,7 +40,7 @@ async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None await api.request(DeviceUpgradeRequest.create(obj_id)) -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): """Validate and load entities from different UniFi handlers.""" @@ -48,7 +48,7 @@ class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): state_fn: Callable[[aiounifi.Controller, _DataT], bool] -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescription( UpdateEntityDescription, UnifiEntityDescription[_HandlerT, _DataT], diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 0be18249e31..f32b53a5d7a 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -42,14 +42,14 @@ _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index bc93c156866..01bde0d9248 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -28,7 +28,7 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ProtectButtonEntityDescription( ProtectSetableKeysMixin[T], ButtonEntityDescription ): diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 9f57b92163c..7f5612a72a8 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -36,7 +36,7 @@ class PermRequired(int, Enum): DELETE = 3 -@dataclass +@dataclass(frozen=True) class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -101,7 +101,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): return bool(get_nested_attr(obj, ufp_required_field)) -@dataclass +@dataclass(frozen=True) class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" @@ -127,7 +127,7 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return value -@dataclass +@dataclass(frozen=True) class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): """Mixin for settable values.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 08bc9f38527..7fed79499d2 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -26,7 +26,7 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd -@dataclass +@dataclass(frozen=True) class NumberKeysMixin: """Mixin for required keys.""" @@ -35,7 +35,7 @@ class NumberKeysMixin: ufp_step: int | float -@dataclass +@dataclass(frozen=True) class ProtectNumberEntityDescription( ProtectSetableKeysMixin[T], NumberEntityDescription, NumberKeysMixin ): diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7605be17fc9..649c77bed5b 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -92,7 +92,7 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -@dataclass +@dataclass(frozen=True) class ProtectSelectEntityDescription( ProtectSetableKeysMixin[T], SelectEntityDescription ): diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 756da49eb4d..6344b852b63 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -@dataclass +@dataclass(frozen=True) class ProtectSensorEntityDescription( ProtectRequiredKeysMixin[T], SensorEntityDescription ): @@ -71,7 +71,7 @@ class ProtectSensorEntityDescription( return value -@dataclass +@dataclass(frozen=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f1e6185b010..e4cb4b7ff46 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -31,7 +31,7 @@ ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" -@dataclass +@dataclass(frozen=True) class ProtectSwitchEntityDescription( ProtectSetableKeysMixin[T], SwitchEntityDescription ): diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index c39f7895231..de777121ff5 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -24,7 +24,7 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd -@dataclass +@dataclass(frozen=True) class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 0ab8962077b..676b9588ddb 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -18,7 +18,7 @@ from .const import DOMAIN, LOGGER, WAN_STATUS from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpBinarySensorEntityDescription( UpnpEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index add8039345b..504602372f7 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import UpnpDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 46d748f6939..e493118f58e 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -41,7 +41,7 @@ from .coordinator import UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription): """A class that describes a sensor UPnP entities.""" diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 7776a3398c7..b30c632174a 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -20,14 +20,14 @@ from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Trydan], bool] -@dataclass +@dataclass(frozen=True) class V2CBinarySensorEntityDescription( BinarySensorEntityDescription, V2CRequiredKeysMixin ): diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 0f2551818a2..dd20b0de787 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -24,7 +24,7 @@ MIN_INTENSITY = 6 MAX_INTENSITY = 32 -@dataclass +@dataclass(frozen=True) class V2CSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class V2CSettingsRequiredKeysMixin: update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class V2CSettingsNumberEntityDescription( NumberEntityDescription, V2CSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index ed642510a34..0aa727fa408 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -25,14 +25,14 @@ from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrydanData], float] -@dataclass +@dataclass(frozen=True) class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): """Describes an EVSE Power sensor entity.""" diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index 4e56e72dcbf..a876af75d86 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -21,7 +21,7 @@ from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class V2CRequiredKeysMixin: turn_off_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): """Describes a V2C EVSE switch entity.""" diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 05085c24424..00c25897d1c 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -41,14 +41,14 @@ class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): return self.coordinator.data.get_metric(self.entity_description.metric_key) == 1 -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxBinarySensorEntityDescription( BinarySensorEntityDescription, ValloxMetricKeyMixin ): diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index ce43ca9c3fb..fa5dfff4a6d 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -60,14 +60,14 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricMixin: """Holds Vallox metric key.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxNumberEntityDescription(NumberEntityDescription, ValloxMetricMixin): """Describes Vallox number entity.""" diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index ee0e1e43204..af5994b66d9 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -125,7 +125,7 @@ class ValloxCellStateSensor(ValloxSensorEntity): return VALLOX_CELL_STATE_TO_STR.get(super_native_value) -@dataclass +@dataclass(frozen=True) class ValloxSensorEntityDescription(SensorEntityDescription): """Describes Vallox sensor entity.""" diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 194659d40cd..8e7835e0bd7 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -63,14 +63,14 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxSwitchEntityDescription(SwitchEntityDescription, ValloxMetricKeyMixin): """Describes Vallox switch entity.""" diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 7125dfd4540..1e31fb9407b 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -65,7 +65,7 @@ SCHEDULE_PARTS: dict[int, str] = { } -@dataclass +@dataclass(frozen=True) class VenstarSensorTypeMixin: """Mixin for sensor required keys.""" @@ -74,7 +74,7 @@ class VenstarSensorTypeMixin: uom_fn: Callable[[Any], str | None] -@dataclass +@dataclass(frozen=True) class VenstarSensorEntityDescription(SensorEntityDescription, VenstarSensorTypeMixin): """Base description of a Sensor entity.""" diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 4277460c3ea..97a557ef49f 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -35,14 +35,14 @@ from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescription( SensorEntityDescription, VeSyncSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 2e3284c37c4..603a42bae41 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -37,14 +37,14 @@ _LOGGER = logging.getLogger(__name__) _TOKEN_FILENAME = "vicare_token.save" -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixin: """Mixin for required keys.""" value_getter: Callable[[Device], Any] -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): """Mixin for required keys with setter.""" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 95a4bcdc9f0..f3cf585b470 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -35,7 +35,7 @@ from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareBinarySensorEntityDescription( BinarySensorEntityDescription, ViCareRequiredKeysMixin ): diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 374d98b3397..8f11fdf0ac5 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -28,7 +28,7 @@ from .utils import is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareButtonEntityDescription( ButtonEntityDescription, ViCareRequiredKeysMixinWithSet ): diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 965b5a619fc..d4dd0437b04 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -37,7 +37,7 @@ from .utils import get_circuits, is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 875d8790c52..142e3cbabfa 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -58,7 +58,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = { } -@dataclass +@dataclass(frozen=True) class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 511e25bbfba..c72edf1b7db 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -24,14 +24,14 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class VilfoRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): """Describes Vilfo sensor entity.""" diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 7f93f8023ef..3840af3d593 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -20,7 +20,7 @@ from .const import _LOGGER, DOMAIN from .coordinator import VodafoneStationRouter -@dataclass +@dataclass(frozen=True) class VodafoneStationBaseEntityDescriptionMixin: """Mixin to describe a Button entity.""" @@ -28,7 +28,7 @@ class VodafoneStationBaseEntityDescriptionMixin: is_suitable: Callable[[dict], bool] -@dataclass +@dataclass(frozen=True) class VodafoneStationEntityDescription( ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 8d9cb444fc9..fcf26c6eb55 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -24,7 +24,7 @@ from .coordinator import VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -@dataclass +@dataclass(frozen=True) class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" @@ -34,7 +34,7 @@ class VodafoneStationBaseEntityDescription: is_suitable: Callable[[dict], bool] = lambda val: True -@dataclass +@dataclass(frozen=True) class VodafoneStationEntityDescription( VodafoneStationBaseEntityDescription, SensorEntityDescription ): diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index b47eb14d58a..76cf8316959 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -38,7 +38,7 @@ def min_charging_current_value(coordinator: WallboxCoordinator) -> float: return 6 -@dataclass +@dataclass(frozen=True) class WallboxNumberEntityDescriptionMixin: """Load entities from different handlers.""" @@ -47,7 +47,7 @@ class WallboxNumberEntityDescriptionMixin: set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] -@dataclass +@dataclass(frozen=True) class WallboxNumberEntityDescription( NumberEntityDescription, WallboxNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4a1cf365bb1..5a825722d53 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -51,7 +51,7 @@ UPDATE_INTERVAL = 30 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): """Describes Wallbox sensor entity.""" diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index d94a2e19f67..43be729e10f 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -153,7 +153,7 @@ async def async_setup_platform( ) -@dataclass +@dataclass(frozen=True) class WAQIMixin: """Mixin for required keys.""" @@ -161,7 +161,7 @@ class WAQIMixin: value_fn: Callable[[WAQIAirQuality], StateType] -@dataclass +@dataclass(frozen=True) class WAQISensorEntityDescription(SensorEntityDescription, WAQIMixin): """Describes WAQI sensor entity.""" diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index f3e5b8744e6..bbdd79e1533 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -46,7 +46,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN, LOGGER, format_dispatch_call -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorRequiredKeysMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ def precipitation_raw_conversion_fn(raw_data: Enum): return raw_data.name.lower() -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorEntityDescription( SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin ): diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 2547dc0ad0d..ecb0c16055c 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -22,7 +22,7 @@ from .entity import WemoEntity from .wemo_device import DeviceCoordinator -@dataclass +@dataclass(frozen=True) class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index c3cad90e045..227c0e9f653 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -89,14 +89,14 @@ def washer_state(washer: WasherDryer) -> str | None: return MACHINE_STATE.get(machine_state, None) -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescription( SensorEntityDescription, WhirlpoolSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 0116f542a3c..7118701a868 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -27,7 +27,7 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WhoisSensorEntityDescription(SensorEntityDescription): """Describes a Whois sensor entity.""" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 36ac9ea7d73..c7ff66e9b4d 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -58,7 +58,7 @@ from .coordinator import ( from .entity import WithingsEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -236,7 +236,7 @@ MEASUREMENT_SENSORS: dict[ } -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsSleepSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -396,7 +396,7 @@ SLEEP_SENSORS = [ ] -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsActivitySensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -494,7 +494,7 @@ SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsGoalsSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -531,7 +531,7 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsWorkoutSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 76c4b197534..91436674d7f 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -22,7 +22,7 @@ from .entity import WizEntity from .models import WizData -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WizNumberEntityDescription(NumberEntityDescription): """Class to describe a WiZ number entity.""" diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 9ab5554a6b7..0fa7d464722 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -39,7 +39,7 @@ async def async_setup_entry( update_segments() -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WLEDNumberEntityDescription(NumberEntityDescription): """Class describing WLED number entities.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 64cc3dc2812..709edaf424f 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -31,7 +31,7 @@ from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WLEDSensorEntityDescription(SensorEntityDescription): """Describes WLED sensor entity.""" diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 130b5ebd922..e1b06175493 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -44,7 +44,7 @@ ATTR_WATER_BOX_ATTACHED = "is_water_box_attached" ATTR_WATER_SHORTAGE = "is_water_shortage" -@dataclass +@dataclass(frozen=True) class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index e5e11b85e58..4ebbf34f295 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -37,7 +37,7 @@ ATTR_RESET_VACUUM_FILTER = "reset_vacuum_filter" ATTR_RESET_VACUUM_SENSOR_DIRTY = "reset_vacuum_sensor_dirty" -@dataclass +@dataclass(frozen=True) class XiaomiMiioButtonDescription(ButtonEntityDescription): """A class that describes button entities.""" diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 1062b2d42b0..2660a1b2be1 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -108,14 +108,14 @@ ATTR_OSCILLATION_ANGLE = "angle" ATTR_VOLUME = "volume" -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberMixin: """A class that describes number entities.""" method: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberDescription(NumberEntityDescription, XiaomiMiioNumberMixin): """A class that describes number entities.""" diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index f6123ad0f0c..b70dab1921a 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -71,7 +71,7 @@ ATTR_MODE = "mode" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class XiaomiMiioSelectDescription(SelectEntityDescription): """A class that describes select entities.""" diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 200a67e5f54..a8435d6a8a1 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -150,7 +150,7 @@ ATTR_CONSUMABLE_STATUS_FILTER_LEFT = "filter_left" ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT = "sensor_dirty_left" -@dataclass +@dataclass(frozen=True) class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 7de6192e736..68714f1a6ff 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -219,7 +219,7 @@ MODEL_TO_FEATURES_MAP = { } -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchRequiredKeyMixin: """A class that describes switch entities.""" @@ -228,7 +228,7 @@ class XiaomiMiioSwitchRequiredKeyMixin: method_off: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchDescription( SwitchEntityDescription, XiaomiMiioSwitchRequiredKeyMixin ): diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 9d702ff52eb..da698d1b501 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -27,14 +27,14 @@ from .entity import YALEXSBLEEntity from .models import YaleXSBLEData -@dataclass +@dataclass(frozen=True) class YaleXSBLERequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[LockState, LockInfo, ConnectionInfo], int | float | None] -@dataclass +@dataclass(frozen=True) class YaleXSBLESensorEntityDescription( SensorEntityDescription, YaleXSBLERequiredKeysMixin ): diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index e65896cdd42..0650cc3a203 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -28,7 +28,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): """YoLink BinarySensorEntityDescription.""" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 2fc4a2b0725..4ac9379d763 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -48,14 +48,14 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSensorEntityDescriptionMixin: """Mixin for device type.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True -@dataclass +@dataclass(frozen=True) class YoLinkSensorEntityDescription( YoLinkSensorEntityDescriptionMixin, SensorEntityDescription ): diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 81c2b46a840..4a35e9506e9 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -23,7 +23,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSirenEntityDescription(SirenEntityDescription): """YoLink SirenEntityDescription.""" diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 018fcb84988..69a958ba6d1 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -29,7 +29,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 99cd3ecf095..d037a8c3c4b 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -26,7 +26,7 @@ from .const import ( from .entity import YouTubeChannelEntity -@dataclass +@dataclass(frozen=True) class YouTubeMixin: """Mixin for required keys.""" @@ -36,7 +36,7 @@ class YouTubeMixin: attributes_fn: Callable[[Any], dict[str, Any] | None] | None -@dataclass +@dataclass(frozen=True) class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin): """Describes YouTube sensor entity.""" diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 344ba560f6a..adc07212a5f 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -37,14 +37,14 @@ from .const import ( from .coordinator import ZamgDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ZamgRequiredKeysMixin: """Mixin for required keys.""" para_name: str -@dataclass +@dataclass(frozen=True) class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin): """Describes Zamg sensor entity.""" diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index ee9aa5531c8..9e2333a1e24 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -22,14 +22,14 @@ from .coordinator import ZeversolarCoordinator from .entity import ZeversolarEntity -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[zeversolar.ZeverSolarData], zeversolar.kWh | zeversolar.Watt] -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescription( SensorEntityDescription, ZeversolarEntityDescriptionMixin ): diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index acd6780d39f..cb460f37000 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -50,7 +50,7 @@ NOTIFICATION_IRRIGATION = "17" NOTIFICATION_GAS = "18" -@dataclass +@dataclass(frozen=True) class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): """Represent a Z-Wave JS binary sensor entity description.""" @@ -58,14 +58,14 @@ class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): states: tuple[str, ...] | None = None -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSMixin: """Represent the mixin for property sensor descriptions.""" on_states: tuple[str, ...] -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSEntityDescription( BinarySensorEntityDescription, PropertyZWaveJSMixin ): diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 02c6abbc732..14a43bea3af 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -34,7 +34,7 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescriptionRequiredKeys: """A class for humidifier entity description required keys.""" @@ -48,7 +48,7 @@ class ZwaveHumidifierEntityDescriptionRequiredKeys: setpoint_type: HumidityControlSetpointType -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescription( HumidifierEntityDescription, ZwaveHumidifierEntityDescriptionRequiredKeys ): diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index 89048f4fec9..f96e2d789ff 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -31,7 +31,7 @@ from . import ZWaveMeController, ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform -@dataclass +@dataclass(frozen=True) class ZWaveMeSensorEntityDescription(SensorEntityDescription): """Class describing ZWaveMeSensor sensor entities.""" diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 456fc4f1570..858084bcabc 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -45,6 +45,7 @@ def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: @dataclass_transform( field_specifiers=(dataclasses.field, dataclasses.Field), + frozen_default=True, # Set to allow setting frozen in child classes kw_only_default=True, # Set to allow setting kw_only in child classes ) class FrozenOrThawed(type): From 69fccec14729244c98cf6b41504efad687334256 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 19 Dec 2023 01:30:02 -0500 Subject: [PATCH 503/927] Clean up device registry for doors that no longer exist in Aladdin Connect (#99743) * Remove devices that no longer exist * Run Black after merge * config 2 devices then 1 devices * clean up device assertions * More generic device check * Add request from Honeywell PR * remove unnecesary test optimize dont_remove * remove unnecessary test * Actually test same id different domain * Test correct id * refactor remove test * Remove .get for non optional keys * Comprehension for all_device_ids * Fix DR test, remove `remove` * fix entities for full test coverage * remove unused variable assignment * Additional assertions confirming other domain * Assertion error * new method for identifier loop * device_entries for lists --- .../components/aladdin_connect/cover.py | 29 ++++ tests/components/aladdin_connect/test_init.py | 128 +++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 604ac61300d..f4104a39365 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,6 +34,34 @@ async def async_setup_entry( async_add_entities( (AladdinDevice(acc, door, config_entry) for door in doors), ) + remove_stale_devices(hass, config_entry, doors) + + +def remove_stale_devices( + hass: HomeAssistant, config_entry: ConfigEntry, devices: list[dict] +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids = {f"{door['device_id']}-{door['door_number']}" for door in devices} + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class AladdinDevice(CoverEntity): diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 294ec81b970..2fc09d1641d 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -7,10 +7,14 @@ from aiohttp import ClientConnectionError from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import DEVICE_CONFIG_OPEN from tests.common import AsyncMock, MockConfigEntry CONFIG = {"username": "test-user", "password": "test-password"} +ID = "533255-1" async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: @@ -40,7 +44,7 @@ async def test_setup_login_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.return_value = False @@ -59,7 +63,7 @@ async def test_setup_connection_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.side_effect = ClientConnectionError @@ -75,7 +79,7 @@ async def test_setup_component_no_error(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) with patch( @@ -116,7 +120,7 @@ async def test_load_and_unload( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) @@ -133,3 +137,119 @@ async def test_load_and_unload( assert await config_entry.async_unload(hass) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_stale_device_removal( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup missing door device is removed.""" + DEVICE_CONFIG_DOOR_2 = { + "device_id": 533255, + "door_number": 2, + "name": "home 2", + "status": "open", + "link_status": "Connected", + "serial": "12346", + "model": "02", + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + unique_id=ID, + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.get_doors = AsyncMock( + return_value=[DEVICE_CONFIG_OPEN, DEVICE_CONFIG_DOOR_2] + ) + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data=CONFIG, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, + identifiers={("OtherDomain", "533255-2")}, + ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, "533255-2")}, + ) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_registry = dr.async_get(hass) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert any((DOMAIN, "533255-2") in device.identifiers for device in device_entries) + assert any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + mock_aladdinconnect_api.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 1 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert not any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries + ) + assert not any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) From 09a0ace6713e0f940a93d0a45088d9b35371c887 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 18 Dec 2023 22:49:18 -0800 Subject: [PATCH 504/927] Fix opower for AEP utilities (#106010) --- homeassistant/components/opower/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 239f23e7523..a474255e34d 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -93,7 +93,9 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ( self.api.utility.subdomain(), account.meter_type.name.lower(), - account.utility_account_id, + # Some utilities like AEP have "-" in their account id. + # Replace it with "_" to avoid "Invalid statistic_id" + account.utility_account_id.replace("-", "_"), ) ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" From 061c144fe8d26c042f00a8799ffc4df07bb94845 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 19 Dec 2023 01:58:35 -0500 Subject: [PATCH 505/927] Correct logic in honeywell for cleaning up stale devices (#106018) * code quality fixes * remove unnecessary code * Remove comment * change config entry configuration order * update based on aladdin connect pr --- homeassistant/components/honeywell/climate.py | 16 ++--- tests/components/honeywell/test_init.py | 67 ++++++++++++++----- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index dfac69b3aed..7281c5740ef 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -118,23 +118,17 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids: set = set() - for device in devices.values(): - all_device_ids.add(device.deviceid) + all_device_ids = {device.deviceid for device in devices.values()} for device_entry in device_entries: device_id: str | None = None - remove = True for identifier in device_entry.identifiers: - if identifier[0] != DOMAIN: - remove = False - continue + if identifier[0] == DOMAIN: + device_id = identifier[1] + break - device_id = identifier[1] - break - - if remove and (device_id is None or device_id not in all_device_ids): + if device_id is None or device_id not in all_device_ids: # If device_id is None an invalid device entry was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 695688e77f0..ccfc2c5d264 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -132,28 +132,51 @@ async def test_remove_stale_device( """Test that the stale device is removed.""" location.devices_by_id[another_device.deviceid] = another_device - config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data={}, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, identifiers={("OtherDomain", 7654321)}, ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, 7654321)}, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert ( - hass.states.async_entity_ids_count() == 6 - ) # 2 climate entities; 4 sensor entities + assert hass.states.async_entity_ids_count() == 6 - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 3 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) - assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, 7654321) in device.identifiers for device in device_entries_other ) assert await config_entry.async_unload(hass) @@ -169,11 +192,21 @@ async def test_remove_stale_device( hass.states.async_entity_ids_count() == 3 ) # 1 climate entities; 2 sensor entities - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 2 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) - assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + assert len(device_entries) == 1 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert not any((DOMAIN, 7654321) in device.identifiers for device in device_entries) + assert not any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other ) From 458fcc63723a6b2db9d73535230e7ba0478653a5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 19 Dec 2023 09:58:02 +0100 Subject: [PATCH 506/927] Add significant Change support for alarm control panel (#106021) --- .../alarm_control_panel/significant_change.py | 38 ++++++++++++++ .../test_significant_change.py | 51 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/significant_change.py create mode 100644 tests/components/alarm_control_panel/test_significant_change.py diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py new file mode 100644 index 00000000000..d33347a67f1 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -0,0 +1,38 @@ +"""Helper to test significant Alarm Control Panel state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name in SIGNIFICANT_ATTRIBUTES: + return True + + # no significant attribute change detected + return False diff --git a/tests/components/alarm_control_panel/test_significant_change.py b/tests/components/alarm_control_panel/test_significant_change.py new file mode 100644 index 00000000000..d65a1d5cb00 --- /dev/null +++ b/tests/components/alarm_control_panel/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Alarm Control Panel significant change platform.""" +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, + ATTR_CODE_FORMAT, +) +from homeassistant.components.alarm_control_panel.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Alarm Control Panel significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "old_value"}, False), + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "new_value"}, True), + ( + {ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CODE_ARM_REQUIRED: "new_value"}, + True, + ), + # multiple attributes + ( + {ATTR_CHANGED_BY: "old_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CHANGED_BY: "new_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + True, + ), + # insignificant attributes + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "old_value"}, False), + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "new_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 24191545a19baa18133ef77517d699564c47e699 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 19 Dec 2023 10:26:54 +0100 Subject: [PATCH 507/927] Bump devolo_home_control_api to 0.18.3 (#106035) --- homeassistant/components/devolo_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 71ca03f9638..eb85e827551 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["devolo_home_control_api"], "quality_scale": "gold", - "requirements": ["devolo-home-control-api==0.18.2"], + "requirements": ["devolo-home-control-api==0.18.3"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a17e29841a..5f0e31dd7a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -689,7 +689,7 @@ denonavr==0.11.4 devialet==1.4.3 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43ae6db8d57..45beddaf33c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ denonavr==0.11.4 devialet==1.4.3 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 From ed9e73898515d38c2ae3d7c3fc2f1c76e5bda7e3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 10:35:04 +0100 Subject: [PATCH 508/927] Remove ipma entity description required fields mixin (#106039) --- homeassistant/components/ipma/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index d779a7ae02a..99e994069a5 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -23,18 +23,13 @@ from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) -class IPMARequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class IPMASensorEntityDescription(SensorEntityDescription): + """Describes a IPMA sensor entity.""" value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] -@dataclass(frozen=True) -class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin): - """Describes IPMA sensor entity.""" - - async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) From 40f30675dd25713a1f25f00159d303874c96c07b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 10:35:19 +0100 Subject: [PATCH 509/927] Remove gree switch entity description required fields mixin (#105849) --- homeassistant/components/gree/switch.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index b9c8491e0f1..07e88223306 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -21,19 +21,14 @@ from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity -@dataclass(frozen=True) -class GreeRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True, frozen=True) +class GreeSwitchEntityDescription(SwitchEntityDescription): + """Describes a Gree switch entity.""" get_value_fn: Callable[[Device], bool] set_value_fn: Callable[[Device, bool], None] -@dataclass(frozen=True) -class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): - """Describes Gree switch entity.""" - - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" device.light = value From 8c3911ffea68f02da636909c67a091a64e13d32c Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 19 Dec 2023 04:36:13 -0500 Subject: [PATCH 510/927] Bump blinkpy 0.22.4 (#105993) --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index db3ab91de11..a1268919052 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.3"] + "requirements": ["blinkpy==0.22.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f0e31dd7a5..cba45c91a21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -547,7 +547,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.3 +blinkpy==0.22.4 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45beddaf33c..a7e721ab20a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.3 +blinkpy==0.22.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 From a4cb64e20e51388273c4194fb2c495d93341beef Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:38:13 +0100 Subject: [PATCH 511/927] Add significant Change support for water heater (#106003) --- .../water_heater/significant_change.py | 76 +++++++++++++++ .../water_heater/test_significant_change.py | 96 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 homeassistant/components/water_heater/significant_change.py create mode 100644 tests/components/water_heater/test_significant_change.py diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py new file mode 100644 index 00000000000..903c80bb714 --- /dev/null +++ b/homeassistant/components/water_heater/significant_change.py @@ -0,0 +1,76 @@ +"""Helper to test significant Water Heater state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_TEMPERATURE, + ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_OPERATION_MODE, + ATTR_AWAY_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/water_heater/test_significant_change.py b/tests/components/water_heater/test_significant_change.py new file mode 100644 index 00000000000..40803eea09a --- /dev/null +++ b/tests/components/water_heater/test_significant_change.py @@ -0,0 +1,96 @@ +"""Test the Water Heater significant change platform.""" +import pytest + +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.water_heater.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Water Heater significant state changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "old_value"}, False), + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_OPERATION_MODE: "old_value"}, + {ATTR_OPERATION_MODE: "new_value"}, + True, + ), + # multiple attributes + ( + METRIC, + {ATTR_AWAY_MODE: "old_value", ATTR_OPERATION_MODE: "old_value"}, + {ATTR_AWAY_MODE: "new_value", ATTR_OPERATION_MODE: "old_value"}, + True, + ), + # float attributes + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.4}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + False, + ), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 161}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 160.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.4}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Water Heater significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) From ef59394ef478d852942cb10cb3424d1ac9094e1a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Dec 2023 10:38:56 +0100 Subject: [PATCH 512/927] Add binary sensor platform to Tailwind integration (#106033) --- homeassistant/components/tailwind/__init__.py | 2 +- .../components/tailwind/binary_sensor.py | 78 ++++++++++ homeassistant/components/tailwind/entity.py | 25 +++ .../components/tailwind/strings.json | 9 ++ .../snapshots/test_binary_sensor.ambr | 145 ++++++++++++++++++ .../components/tailwind/test_binary_sensor.py | 31 ++++ 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tailwind/binary_sensor.py create mode 100644 tests/components/tailwind/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/tailwind/test_binary_sensor.py diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 8f8ef4134a9..661e4489f74 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator -PLATFORMS = [Platform.BUTTON, Platform.NUMBER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py new file mode 100644 index 00000000000..e558349b8f7 --- /dev/null +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -0,0 +1,78 @@ +"""Binary sensor entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from gotailwind.models import TailwindDoor + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +@dataclass(kw_only=True, frozen=True) +class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tailwind door binary sensor entities.""" + + is_on_fn: Callable[[TailwindDoor], bool] + + +DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( + TailwindDoorBinarySensorEntityDescription( + key="locked_out", + translation_key="operational_status", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:garage-alert", + is_on_fn=lambda door: not door.locked_out, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind binary sensor based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorBinarySensorEntity(coordinator, description, door_id) + for description in DESCRIPTIONS + for door_id in coordinator.data.doors + ) + + +class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): + """Representation of a Tailwind door binary sensor entity.""" + + entity_description: TailwindDoorBinarySensorEntityDescription + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + description: TailwindDoorBinarySensorEntityDescription, + door_id: str, + ) -> None: + """Initiate Tailwind button entity.""" + super().__init__(coordinator, door_id) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{description.key}" + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.data.doors[self.door_id] + ) diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index 1077e2eb888..e4b18d5e4da 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -23,3 +23,28 @@ class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): model=coordinator.data.product, sw_version=coordinator.data.firmware_version, ) + + +class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind door entity. + + These are the entities that belong to a specific garage door opener + that is connected via the Tailwind controller. + """ + + _attr_has_entity_name = True + + def __init__( + self, coordinator: TailwindDataUpdateCoordinator, door_id: str + ) -> None: + """Initialize an Tailwind door entity.""" + self.door_id = door_id + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, + via_device=(DOMAIN, coordinator.data.device_id), + name=f"Door {coordinator.data.doors[door_id].index+1}", + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index bc765efa8d1..de5a025cbce 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -46,6 +46,15 @@ } }, "entity": { + "binary_sensor": { + "operational_status": { + "name": "Operational status", + "state": { + "off": "Locked out", + "on": "Operational" + } + } + }, "number": { "brightness": { "name": "Status LED brightness" diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..18145d0274e --- /dev/null +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 1 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- +# name: test_number_entities.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 2 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.5 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py new file mode 100644 index 00000000000..1a8269e8457 --- /dev/null +++ b/tests/components/tailwind/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Tests for binary sensor entities provided by the Tailwind integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor entities provided by the Tailwind integration.""" + for entity_id in ( + "binary_sensor.door_1_operational_status", + "binary_sensor.door_2_operational_status", + ): + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry From bb6f78dcc49a9a6afad56173d5ea01d5ad236b4e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 11:16:35 +0100 Subject: [PATCH 513/927] Remove sun entity description required fields mixin (#105848) --- homeassistant/components/sun/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 437f1626c9d..384e356fdd6 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -26,19 +26,14 @@ from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" -@dataclass(frozen=True) -class SunEntityDescriptionMixin: - """Mixin for required Sun base description keys.""" +@dataclass(kw_only=True, frozen=True) +class SunSensorEntityDescription(SensorEntityDescription): + """Describes a Sun sensor entity.""" value_fn: Callable[[Sun], StateType | datetime] signal: str -@dataclass(frozen=True) -class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): - """Describes Sun sensor entity.""" - - SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="next_dawn", From f20e4b9df13275e6067f3308d6bfbefe985d5f35 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 12:07:13 +0100 Subject: [PATCH 514/927] Add myself as code owner for rest_command (#106047) --- CODEOWNERS | 2 ++ homeassistant/components/rest_command/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index c282e00049f..9264561a0fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1066,6 +1066,8 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @ShadowBr0ther +/homeassistant/components/rest_command/ @jpbede +/tests/components/rest_command/ @jpbede /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 diff --git a/homeassistant/components/rest_command/manifest.json b/homeassistant/components/rest_command/manifest.json index f9acf3b5933..bd3b6070691 100644 --- a/homeassistant/components/rest_command/manifest.json +++ b/homeassistant/components/rest_command/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest_command", "name": "RESTful Command", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/rest_command", "iot_class": "local_push" } From 2d31f9e984c4e10c6206754bb80ace68e54363f0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 12:20:28 +0100 Subject: [PATCH 515/927] Use freezegun in ign_sismologia test (#106051) --- tests/components/ign_sismologia/test_geo_location.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index 02a11b3fe7a..d1e4eb3d115 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.ign_sismologia.geo_location import ( @@ -71,7 +73,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -93,11 +95,10 @@ async def test_setup(hass: HomeAssistant) -> None: ) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_ign_sismologia_client.IgnSismologiaFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_ign_sismologia_client.IgnSismologiaFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], From c50098a845d902a17c3ff16b19ea7932ef131e5c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 12:20:56 +0100 Subject: [PATCH 516/927] Use freezegun in nsw_rural_fire_service_feed test (#106050) --- .../nsw_rural_fire_service_feed/test_geo_location.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index 673ac1a72d4..bf56cb8a985 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -3,6 +3,7 @@ import datetime from unittest.mock import ANY, MagicMock, call, patch from aio_geojson_nsw_rfs_incidents import NswRuralFireServiceIncidentsFeed +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -90,7 +91,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -114,11 +115,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], From f84e43bc0ac08b02328c79966552e939548a279f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 12:21:19 +0100 Subject: [PATCH 517/927] Use freezegun in qld_bushfire test (#106049) --- tests/components/qld_bushfire/test_geo_location.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 18b33a6ef0c..5a9821dd52d 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.qld_bushfire.geo_location import ( @@ -70,7 +72,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -88,11 +90,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (38.2, -3.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_qld_bushfire_alert_client.QldBushfireAlertFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_qld_bushfire_alert_client.QldBushfireAlertFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], From 7d74c89606da070a998d6abe2f9be1c90f3a55fb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Dec 2023 12:26:40 +0100 Subject: [PATCH 518/927] Use freezegun in image_upload test (#106045) --- tests/components/image_upload/test_init.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 486f98e92c2..9f842d25b64 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -4,6 +4,7 @@ import tempfile from unittest.mock import patch from aiohttp import ClientSession, ClientWebSocketResponse +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.websocket_api import const as ws_const from homeassistant.core import HomeAssistant @@ -17,15 +18,17 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_upload_image( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, ) -> None: """Test we can upload an image.""" now = dt_util.utcnow() + freezer.move_to(now) with tempfile.TemporaryDirectory() as tempdir, patch.object( hass.config, "path", return_value=tempdir - ), patch("homeassistant.util.dt.utcnow", return_value=now): + ): assert await async_setup_component(hass, "image_upload", {}) ws_client: ClientWebSocketResponse = await hass_ws_client() client: ClientSession = await hass_client() From a1614d6b7e603a586a3e0c1d1894683827599960 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:30:30 +0100 Subject: [PATCH 519/927] Add significant Change support for climate (#106020) --- .../components/climate/significant_change.py | 104 ++++++++++++++ .../climate/test_significant_change.py | 129 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 homeassistant/components/climate/significant_change.py create mode 100644 tests/components/climate/test_significant_change.py diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py new file mode 100644 index 00000000000..01d3ef98558 --- /dev/null +++ b/homeassistant/components/climate/significant_change.py @@ -0,0 +1,104 @@ +"""Helper to test significant Climate state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + if attr_name in [ + ATTR_AUX_HEAT, + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + absolute_change: float | None = None + if attr_name in [ + ATTR_CURRENT_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + ]: + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in [ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY]: + absolute_change = 1.0 + + if absolute_change and check_absolute_change( + old_attr_value, new_attr_value, absolute_change + ): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py new file mode 100644 index 00000000000..369e5e67004 --- /dev/null +++ b/tests/components/climate/test_significant_change.py @@ -0,0 +1,129 @@ +"""Test the Climate significant change platform.""" +import pytest + +from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.climate.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Climate significant state_changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "old_value"}, False), + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "new_value"}, True), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "old_value"}, False), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "new_value"}, + True, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "new_value"}, + True, + ), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True), + # multiple attributes + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value", ATTR_PRESET_MODE: "old_value"}, + {ATTR_HVAC_ACTION: "new_value", ATTR_PRESET_MODE: "old_value"}, + True, + ), + # float attributes + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + {ATTR_CURRENT_HUMIDITY: 60.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: 60.0}, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.4}, + False, + ), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 61.0}, True), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 60.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.4}, False), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.5}, True), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.4}, False), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 71.0}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 70.9}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Climate significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) From c87f2027d486e45f87c56b57ef53ef847145c7ab Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:31:10 +0100 Subject: [PATCH 520/927] Use check_valid_float helper in significant change support of sensor and weather (#106013) --- .../components/sensor/significant_change.py | 16 +++++++--------- .../components/weather/significant_change.py | 18 ++++++------------ 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 1a4dc65f010..f426674c32d 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.significant_change import ( check_absolute_change, check_percentage_change, + check_valid_float, ) from . import SensorDeviceClass @@ -63,23 +64,20 @@ def async_check_significant_change( absolute_change = 1.0 percentage_change = 2.0 - try: + if not check_valid_float(new_state): # New state is invalid, don't report it - new_state_f = float(new_state) - except ValueError: return False - try: + if not check_valid_float(old_state): # Old state was invalid, we should report again - old_state_f = float(old_state) - except ValueError: return True if absolute_change is not None and percentage_change is not None: return _absolute_and_relative_change( - old_state_f, new_state_f, absolute_change, percentage_change + float(old_state), float(new_state), absolute_change, percentage_change ) if absolute_change is not None: - return check_absolute_change(old_state_f, new_state_f, absolute_change) - + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index bd6571a390e..4bb67c54e19 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -5,7 +5,10 @@ from typing import Any from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.significant_change import check_absolute_change +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) from .const import ( ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -60,15 +63,6 @@ VALID_CARDINAL_DIRECTIONS: list[str] = [ ] -def _check_valid_float(value: str | int | float) -> bool: - """Check if given value is a valid float.""" - try: - float(value) - except ValueError: - return False - return True - - def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: """Translate a cardinal direction into azimuth angle (degrees).""" if not isinstance(value, str): @@ -109,11 +103,11 @@ def async_check_significant_change( old_attr_value = _cardinal_to_degrees(old_attr_value) new_attr_value = _cardinal_to_degrees(new_attr_value) - if new_attr_value is None or not _check_valid_float(new_attr_value): + if new_attr_value is None or not check_valid_float(new_attr_value): # New attribute value is invalid, ignore it continue - if old_attr_value is None or not _check_valid_float(old_attr_value): + if old_attr_value is None or not check_valid_float(old_attr_value): # Old attribute value was invalid, we should report again return True From c64c1c8f080a1b56dbd9341d5c04986d11013e07 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Dec 2023 12:33:05 +0100 Subject: [PATCH 521/927] Workday create repair if named holiday missing (#101201) --- .../components/workday/binary_sensor.py | 22 ++++- homeassistant/components/workday/repairs.py | 77 +++++++++++++++++- homeassistant/components/workday/strings.json | 20 +++++ tests/components/workday/test_repairs.py | 80 ++++++++++++++++++- 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e2369baade5..bda3a576563 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) -from homeassistant.util import dt as dt_util +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import dt as dt_util, slugify from .const import ( ALLOWED_DAYS, @@ -122,6 +123,25 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index fbed179763e..905434f76ac 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -18,7 +18,8 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_PROVINCE +from .config_flow import validate_custom_dates +from .const import CONF_PROVINCE, CONF_REMOVE_HOLIDAYS class CountryFixFlow(RepairsFlow): @@ -108,6 +109,76 @@ class CountryFixFlow(RepairsFlow): ) +class HolidayFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__( + self, entry: ConfigEntry, country: str | None, named_holiday: str + ) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + self.named_holiday: str = named_holiday + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_named_holiday() + + async def async_step_named_holiday( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the options step of a fix flow.""" + errors: dict[str, str] = {} + if user_input: + options = dict(self.entry.options) + new_options = {**options, **user_input} + try: + await self.hass.async_add_executor_job( + validate_custom_dates, new_options + ) + except Exception: # pylint: disable=broad-except + errors["remove_holidays"] = "remove_holiday_error" + else: + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + remove_holidays = self.entry.options[CONF_REMOVE_HOLIDAYS] + removed_named_holiday = [ + value for value in remove_holidays if value != self.named_holiday + ] + new_schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, + ) + return self.async_show_form( + step_id="named_holiday", + data_schema=new_schema, + description_placeholders={ + CONF_COUNTRY: self.country if self.country else "-", + CONF_REMOVE_HOLIDAYS: self.named_holiday, + "title": self.entry.title, + }, + errors=errors, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -119,6 +190,10 @@ async def async_create_fix_flow( entry_id = cast(str, entry_id) entry = hass.config_entries.async_get_entry(entry_id) + if data and (holiday := data.get("named_holiday")) and entry: + # Bad named holiday in configuration + return HolidayFixFlow(entry, data.get("country"), holiday) + if data and entry: # Country or province does not exist return CountryFixFlow(entry, data.get("country")) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 7e8439af5ea..bbb76676f96 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -132,6 +132,26 @@ } } } + }, + "bad_named_holiday": { + "title": "Configured named holiday {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "named_holiday": { + "title": "[%key:component::workday::issues::bad_named_holiday::title%]", + "description": "Remove named holiday `{remove_holidays}` as it can't be found in country {country}.", + "data": { + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]" + }, + "data_description": { + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]" + } + } + }, + "error": { + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" + } + } } }, "entity": { diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index d1920b7dc26..fc7bfeb1b0e 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -7,7 +7,7 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowIndexView, RepairsFlowResourceView, ) -from homeassistant.components.workday.const import DOMAIN +from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import async_create_issue @@ -16,6 +16,7 @@ from homeassistant.setup import async_setup_component from . import ( TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_REMOVE_NAMED, init_integration, ) @@ -324,6 +325,83 @@ async def test_bad_province_none( assert not issue +async def test_bad_named_holiday( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post( + url, + json={"handler": DOMAIN, "issue_id": "bad_named_holiday-1-not_a_holiday"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "US", + CONF_REMOVE_HOLIDAYS: "Not a Holiday", + "title": entry.title, + } + assert data["step_id"] == "named_holiday" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Not exist 2"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["errors"] == { + CONF_REMOVE_HOLIDAYS: "remove_holiday_error", + } + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Thanksgiving"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert not issue + + async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, From a4ccd6e13ba2025e076207db7016323667de632f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 19 Dec 2023 12:45:32 +0100 Subject: [PATCH 522/927] Deprecate binary sensor device class constants (#105736) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- .../components/binary_sensor/__init__.py | 119 +++++++++++++----- homeassistant/helpers/deprecation.py | 88 ++++++++++++- tests/components/binary_sensor/test_init.py | 27 ++++ tests/helpers/test_deprecation.py | 94 ++++++++++++++ .../binary_sensor.py | 7 ++ 5 files changed, 302 insertions(+), 33 deletions(-) create mode 100644 tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index a3303c525cb..dbed80a83f4 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Literal, final @@ -16,6 +17,10 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -121,34 +126,92 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the BinarySensorDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] -DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value -DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value -DEVICE_CLASS_CO = BinarySensorDeviceClass.CO.value -DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value -DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value -DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value -DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value -DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value -DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value -DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value -DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value -DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value -DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value -DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value -DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value -DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value -DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value -DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value -DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value -DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value -DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value -DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value -DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value -DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value -DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value -DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value -DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum( + BinarySensorDeviceClass.CO, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum( + BinarySensorDeviceClass.COLD, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum( + BinarySensorDeviceClass.CONNECTIVITY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.GARAGE_DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum( + BinarySensorDeviceClass.GAS, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum( + BinarySensorDeviceClass.HEAT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum( + BinarySensorDeviceClass.LIGHT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum( + BinarySensorDeviceClass.LOCK, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOISTURE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOTION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOVING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum( + BinarySensorDeviceClass.OCCUPANCY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum( + BinarySensorDeviceClass.OPENING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum( + BinarySensorDeviceClass.PLUG, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum( + BinarySensorDeviceClass.POWER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum( + BinarySensorDeviceClass.PRESENCE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum( + BinarySensorDeviceClass.PROBLEM, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum( + BinarySensorDeviceClass.RUNNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum( + BinarySensorDeviceClass.SAFETY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum( + BinarySensorDeviceClass.SMOKE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum( + BinarySensorDeviceClass.SOUND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum( + BinarySensorDeviceClass.TAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum( + BinarySensorDeviceClass.UPDATE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum( + BinarySensorDeviceClass.VIBRATION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + BinarySensorDeviceClass.WINDOW, "2025.1" +) + +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) # mypy: disallow-any-generics diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 20dbacde480..740f96044a5 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress +from enum import Enum import functools import inspect import logging -from typing import Any, ParamSpec, TypeVar +from typing import Any, NamedTuple, ParamSpec, TypeVar from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.exceptions import HomeAssistantError @@ -153,7 +154,25 @@ def _print_deprecation_warning( verb: str, breaks_in_ha_version: str | None, ) -> None: - logger = logging.getLogger(obj.__module__) + _print_deprecation_warning_internal( + obj.__name__, + obj.__module__, + replacement, + description, + verb, + breaks_in_ha_version, + ) + + +def _print_deprecation_warning_internal( + obj_name: str, + module_name: str, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, +) -> None: + logger = logging.getLogger(module_name) if breaks_in_ha_version: breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" else: @@ -163,7 +182,7 @@ def _print_deprecation_warning( except MissingIntegrationFrame: logger.warning( "%s is a deprecated %s%s. Use %s instead", - obj.__name__, + obj_name, description, breaks_in, replacement, @@ -183,7 +202,7 @@ def _print_deprecation_warning( "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), - obj.__name__, + obj_name, verb, integration_frame.integration, description, @@ -194,10 +213,69 @@ def _print_deprecation_warning( else: logger.warning( "%s was %s from %s, this is a deprecated %s%s. Use %s instead", - obj.__name__, + obj_name, verb, integration_frame.integration, description, breaks_in, replacement, ) + + +class DeprecatedConstant(NamedTuple): + """Deprecated constant.""" + + value: Any + replacement: str + breaks_in_ha_version: str | None + + +class DeprecatedConstantEnum(NamedTuple): + """Deprecated constant.""" + + enum: Enum + breaks_in_ha_version: str | None + + +def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_name = module_globals.get("__name__") + logger = logging.getLogger(module_name) + if (deprecated_const := module_globals.get(f"_DEPRECATED_{name}")) is None: + raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") + if isinstance(deprecated_const, DeprecatedConstant): + value = deprecated_const.value + replacement = deprecated_const.replacement + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + elif isinstance(deprecated_const, DeprecatedConstantEnum): + value = deprecated_const.enum.value + replacement = ( + f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" + ) + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + else: + msg = ( + f"Value of _DEPRECATED_{name!r} is an instance of {type(deprecated_const)} " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" + ) + + logger.debug(msg) + # PEP 562 -- Module __getattr__ and __dir__ + # specifies that __getattr__ should raise AttributeError if the attribute is not + # found. + # https://peps.python.org/pep-0562/#specification + raise AttributeError(msg) # noqa: TRY004 + + _print_deprecation_warning_internal( + name, + module_name or __name__, + replacement, + "constant", + "used", + breaks_in_ha_version, + ) + return value diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 074ecb4434a..782896b4dce 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -1,5 +1,6 @@ """The tests for the Binary sensor component.""" from collections.abc import Generator +import logging from unittest import mock import pytest @@ -19,6 +20,9 @@ from tests.common import ( mock_platform, ) from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor +from tests.testing_config.custom_components.test_constant_deprecation.binary_sensor import ( + import_deprecated, +) TEST_DOMAIN = "test" @@ -194,3 +198,26 @@ async def test_entity_category_config_raises_error( "Entity binary_sensor.test2 cannot be added as the entity category is set to config" in caplog.text ) + + +@pytest.mark.parametrize( + "device_class", + list(binary_sensor.BinarySensorDeviceClass), +) +def test_deprecated_constant_device_class( + caplog: pytest.LogCaptureFixture, + device_class: binary_sensor.BinarySensorDeviceClass, +) -> None: + """Test deprecated binary sensor device classes.""" + import_deprecated(device_class) + + assert ( + "homeassistant.components.binary_sensor", + logging.WARNING, + ( + f"DEVICE_CLASS_{device_class.name} was used from test_constant_deprecation," + " this is a deprecated constant which will be removed in HA Core 2025.1. " + f"Use BinarySensorDeviceClass.{device_class.name} instead, please report " + "it to the author of the 'test_constant_deprecation' custom integration" + ), + ) in caplog.record_tuples diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 46716263d5b..6cff4781583 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,10 +1,15 @@ """Test deprecation helpers.""" +import logging +import sys from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, deprecated_class, deprecated_function, deprecated_substitute, @@ -247,3 +252,92 @@ def test_deprecated_function_called_from_custom_integration( "Use new_function instead, please report it to the author of the " "'hue' custom integration" ) in caplog.text + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name", "extra_extra_msg"), + [ + ("homeassistant.components.hue.light", ""), # builtin integration + ( + "config.custom_components.hue.light", + ", please report it to the author of the 'hue' custom integration", + ), # custom component integration + ], +) +def test_check_if_deprecated_constant( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum, + extra_msg: str, + module_name: str, + extra_extra_msg: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + filename = f"/home/paulus/{module_name.replace('.', '/')}.py" + + # mock module for homeassistant/helpers/frame.py#get_integration_frame + sys.modules[module_name] = Mock(__file__=filename) + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename=filename, + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == deprecated_constant.value + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT was used from hue, this is a deprecated constant{extra_msg}{extra_extra_msg}", + ) in caplog.record_tuples + + +def test_test_check_if_deprecated_constant_invalid( + caplog: pytest.LogCaptureFixture +) -> None: + """Test check_if_deprecated_constant will raise an attribute error and create an log entry on an invalid deprecation type.""" + module_name = "homeassistant.components.hue.light" + module_globals = {"__name__": module_name, "_DEPRECATED_TEST_CONSTANT": 1} + name = "TEST_CONSTANT" + + excepted_msg = ( + f"Value of _DEPRECATED_{name!r} is an instance of " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" + ) + + with pytest.raises(AttributeError, match=excepted_msg): + check_if_deprecated_constant(name, module_globals) + + assert (module_name, logging.DEBUG, excepted_msg) in caplog.record_tuples diff --git a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py new file mode 100644 index 00000000000..e9e85dfb639 --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py @@ -0,0 +1,7 @@ +"""Test deprecated binary sensor device classes.""" +from homeassistant.components import binary_sensor + + +def import_deprecated(device_class: binary_sensor.BinarySensorDeviceClass): + """Import deprecated device class constant.""" + getattr(binary_sensor, f"DEVICE_CLASS_{device_class.name}") From 48740e6b056c264edcf97d26656225bc79759121 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Dec 2023 12:46:21 +0100 Subject: [PATCH 523/927] Add cover platform to Tailwind integration (#106042) --- homeassistant/components/tailwind/__init__.py | 2 +- homeassistant/components/tailwind/cover.py | 88 +++++++++++ .../tailwind/snapshots/test_cover.ambr | 147 ++++++++++++++++++ tests/components/tailwind/test_cover.py | 76 +++++++++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tailwind/cover.py create mode 100644 tests/components/tailwind/snapshots/test_cover.ambr create mode 100644 tests/components/tailwind/test_cover.py diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 661e4489f74..b71fd1cd0fc 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.BUTTON, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py new file mode 100644 index 00000000000..5a1f9cb8d73 --- /dev/null +++ b/homeassistant/components/tailwind/cover.py @@ -0,0 +1,88 @@ +"""Cover entity platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from gotailwind import TailwindDoorOperationCommand, TailwindDoorState + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind cover based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorCoverEntity(coordinator, door_id) + for door_id in coordinator.data.doors + ) + + +class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): + """Representation of a Tailwind door binary sensor entity.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_is_closing = False + _attr_is_opening = False + _attr_name = None + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + ) -> None: + """Initiate Tailwind button entity.""" + super().__init__(coordinator, door_id) + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + + @property + def is_closed(self) -> bool: + """Return if the cover is closed or not.""" + return ( + self.coordinator.data.doors[self.door_id].state == TailwindDoorState.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door. + + The Tailwind operating command will await the confirmation of the + door being opened before returning. + """ + self._attr_is_opening = True + self.async_write_ha_state() + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.OPEN, + ) + self._attr_is_opening = False + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door. + + The Tailwind operating command will await the confirmation of the + door being closed before returning. + """ + self._attr_is_closing = True + self.async_write_ha_state() + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.CLOSE, + ) + self._attr_is_closing = False + await self.coordinator.async_request_refresh() diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr new file mode 100644 index 00000000000..4e94c1084e4 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_cover_entities[cover.door_1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_1', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- +# name: test_cover_entities[cover.door_2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door2', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py new file mode 100644 index 00000000000..e13ab534e5b --- /dev/null +++ b/tests/components/tailwind/test_cover.py @@ -0,0 +1,76 @@ +"""Tests for cover entities provided by the Tailwind integration.""" +from unittest.mock import ANY, MagicMock + +from gotailwind import TailwindDoorOperationCommand +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "cover.door_1", + "cover.door_2", + ], +) +async def test_cover_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test cover entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +async def test_cover_operations( + hass: HomeAssistant, + mock_tailwind: MagicMock, +) -> None: + """Test operating the doors.""" + assert len(mock_tailwind.operate.mock_calls) == 0 + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.OPEN + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.CLOSE + ) From 5b4000e7595e49b636ac81f3191701609c90fd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 19 Dec 2023 13:08:27 +0100 Subject: [PATCH 524/927] Bump hass-nabucasa from 0.74.0 to 0.75.1 (#105958) * Bump hass-nabucasa from 0.74.0 to 0.75.1 * Force sorting of parametrized test * Simplify async_create_issue.severity * use fixtures --- homeassistant/components/cloud/client.py | 29 +++++++++++- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 8 ++++ homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_client.py | 49 +++++++++++++++++++- 7 files changed, 88 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 019936869a1..cef3c5f0d42 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,7 +6,7 @@ from datetime import datetime from http import HTTPStatus import logging from pathlib import Path -from typing import Any +from typing import Any, Literal import aiohttp from hass_nabucasa.client import CloudClient as Interface @@ -22,12 +22,18 @@ from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN from .prefs import CloudPreferences +VALID_REPAIR_TRANSLATION_KEYS = { + "warn_bad_custom_domain_configuration", + "reset_bad_custom_domain_configuration", +} + class CloudClient(Interface): """Interface class for Home Assistant Cloud.""" @@ -302,3 +308,24 @@ class CloudClient(Interface): ) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) + + async def async_create_repair_issue( + self, + identifier: str, + translation_key: str, + *, + placeholders: dict[str, str] | None = None, + severity: Literal["error", "warning"] = "warning", + ) -> None: + """Create a repair issue.""" + if translation_key not in VALID_REPAIR_TRANSLATION_KEYS: + raise ValueError(f"Invalid translation key {translation_key}") + async_create_issue( + hass=self._hass, + domain=DOMAIN, + issue_id=identifier, + translation_key=translation_key, + translation_placeholders=placeholders, + severity=IssueSeverity(severity), + is_fixable=False, + ) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 6d5c954361b..f7337e1d771 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.74.0"] + "requirements": ["hass-nabucasa==0.75.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 9c1f29cfcaf..8195b78a01e 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -30,6 +30,14 @@ "operation_took_too_long": "The operation took too long. Please try again later." } } + }, + "warn_bad_custom_domain_configuration": { + "title": "Detected wrong custom domain configuration", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." + }, + "reset_bad_custom_domain_configuration": { + "title": "Custom domain ignored", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page." } }, "services": { diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a1af1fe9f5..88f7937bb12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==1.0.0 -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20231208.2 diff --git a/requirements_all.txt b/requirements_all.txt index cba45c91a21..9ba43eb0ef6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -992,7 +992,7 @@ habitipy==0.2.0 habluetooth==1.0.0 # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7e721ab20a..ca091ec8321 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ habitipy==0.2.0 habluetooth==1.0.0 # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.conversation hassil==1.5.1 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index ff718262b10..0cd605fd755 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -7,7 +7,10 @@ from aiohttp import web import pytest from homeassistant.components.cloud import DOMAIN -from homeassistant.components.cloud.client import CloudClient +from homeassistant.components.cloud.client import ( + VALID_REPAIR_TRANSLATION_KEYS, + CloudClient, +) from homeassistant.components.cloud.const import ( PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, @@ -21,6 +24,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import CONTENT_TYPE_JSON, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -381,3 +385,46 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "version": HA_VERSION, "instance_id": "12345678901234567890", } + + +@pytest.mark.parametrize( + "translation_key", + sorted(VALID_REPAIR_TRANSLATION_KEYS), +) +async def test_async_create_repair_issue_known( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, + translation_key: str, +) -> None: + """Test create repair issue for known repairs.""" + identifier = f"test_identifier_{translation_key}" + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key=translation_key, + placeholders={"custom_domains": "example.com"}, + severity="warning", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is not None + + +async def test_async_create_repair_issue_unknown( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, +) -> None: + """Test not creating repair issue for unknown repairs.""" + identifier = "abc123" + with pytest.raises( + ValueError, + match="Invalid translation key unknown_translation_key", + ): + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key="unknown_translation_key", + placeholders={"custom_domains": "example.com"}, + severity="error", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is None From 63136572a54c9b443a1eea25bc22334c1009b143 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Dec 2023 13:35:29 +0100 Subject: [PATCH 525/927] Update gotailwind to 0.2.2 (#106054) --- homeassistant/components/tailwind/binary_sensor.py | 2 +- homeassistant/components/tailwind/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index e558349b8f7..7eec74042e2 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from gotailwind.models import TailwindDoor +from gotailwind import TailwindDoor from homeassistant.components.binary_sensor import ( BinarySensorEntity, diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 5121c8408f0..014b5d3379a 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.2.1"], + "requirements": ["gotailwind==0.2.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9ba43eb0ef6..3d1d69eb5f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -934,7 +934,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.tailwind -gotailwind==0.2.1 +gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca091ec8321..0d02ad7aad5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -745,7 +745,7 @@ google-nest-sdm==3.0.3 googlemaps==2.5.1 # homeassistant.components.tailwind -gotailwind==0.2.1 +gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.24.0 From 0e0fd39603900534ffd26ebe95c4090dad2a9d70 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 19 Dec 2023 16:37:21 +0100 Subject: [PATCH 526/927] Add dir_with_deprecated_constants function to deprecation helper (#106059) --- .../components/binary_sensor/__init__.py | 3 +++ homeassistant/helpers/deprecation.py | 16 ++++++++++-- tests/common.py | 26 +++++++++++++++++++ tests/components/binary_sensor/test_init.py | 16 +++--------- tests/helpers/test_deprecation.py | 20 ++++++++++++++ 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index dbed80a83f4..4372c0ee55b 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, check_if_deprecated_constant, + dir_with_deprecated_constants, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent @@ -211,7 +212,9 @@ _DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( BinarySensorDeviceClass.WINDOW, "2025.1" ) +# Both can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 740f96044a5..fd3fb50efd4 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -237,6 +237,9 @@ class DeprecatedConstantEnum(NamedTuple): breaks_in_ha_version: str | None +_PREFIX_DEPRECATED = "_DEPRECATED_" + + def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> Any: """Check if the not found name is a deprecated constant. @@ -245,7 +248,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A """ module_name = module_globals.get("__name__") logger = logging.getLogger(module_name) - if (deprecated_const := module_globals.get(f"_DEPRECATED_{name}")) is None: + if (deprecated_const := module_globals.get(_PREFIX_DEPRECATED + name)) is None: raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") if isinstance(deprecated_const, DeprecatedConstant): value = deprecated_const.value @@ -259,7 +262,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A breaks_in_ha_version = deprecated_const.breaks_in_ha_version else: msg = ( - f"Value of _DEPRECATED_{name!r} is an instance of {type(deprecated_const)} " + f"Value of {_PREFIX_DEPRECATED}{name!r} is an instance of {type(deprecated_const)} " "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) @@ -279,3 +282,12 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A breaks_in_ha_version, ) return value + + +def dir_with_deprecated_constants(module_globals: dict[str, Any]) -> list[str]: + """Return dir() with deprecated constants.""" + return list(module_globals) + [ + name.removeprefix(_PREFIX_DEPRECATED) + for name in module_globals + if name.startswith(_PREFIX_DEPRECATED) + ] diff --git a/tests/common.py b/tests/common.py index 1d0b278a6cb..05bddec203c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,6 +6,7 @@ from collections import OrderedDict from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from datetime import UTC, datetime, timedelta +from enum import Enum import functools as ft from functools import lru_cache from io import StringIO @@ -15,10 +16,12 @@ import os import pathlib import threading import time +from types import ModuleType from typing import Any, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +import pytest import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -1460,3 +1463,26 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> else: state = CloudConnectionState.CLOUD_DISCONNECTED async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) + + +def validate_deprecated_constant( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + replacement: Enum, + constant_prefix: str, + breaks_in_ha_version: str, +) -> None: + """Validate deprecated constant creates a log entry and is included in the modules.__dir__().""" + assert ( + module.__name__, + logging.WARNING, + ( + f"{constant_prefix}{replacement.name} was used from test_constant_deprecation," + f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " + f"Use {replacement.__class__.__name__}.{replacement.name} instead, please report " + "it to the author of the 'test_constant_deprecation' custom integration" + ), + ) in caplog.record_tuples + + # verify deprecated constant is included in dir() + assert f"{constant_prefix}{replacement.name}" in dir(module) diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 782896b4dce..ac957818be9 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -1,6 +1,5 @@ """The tests for the Binary sensor component.""" from collections.abc import Generator -import logging from unittest import mock import pytest @@ -18,6 +17,7 @@ from tests.common import ( mock_config_flow, mock_integration, mock_platform, + validate_deprecated_constant, ) from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor from tests.testing_config.custom_components.test_constant_deprecation.binary_sensor import ( @@ -210,14 +210,6 @@ def test_deprecated_constant_device_class( ) -> None: """Test deprecated binary sensor device classes.""" import_deprecated(device_class) - - assert ( - "homeassistant.components.binary_sensor", - logging.WARNING, - ( - f"DEVICE_CLASS_{device_class.name} was used from test_constant_deprecation," - " this is a deprecated constant which will be removed in HA Core 2025.1. " - f"Use BinarySensorDeviceClass.{device_class.name} instead, please report " - "it to the author of the 'test_constant_deprecation' custom integration" - ), - ) in caplog.record_tuples + validate_deprecated_constant( + caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 6cff4781583..4ad1677a16f 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,6 +1,7 @@ """Test deprecation helpers.""" import logging import sys +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest @@ -13,6 +14,7 @@ from homeassistant.helpers.deprecation import ( deprecated_class, deprecated_function, deprecated_substitute, + dir_with_deprecated_constants, get_deprecated, ) @@ -341,3 +343,21 @@ def test_test_check_if_deprecated_constant_invalid( check_if_deprecated_constant(name, module_globals) assert (module_name, logging.DEBUG, excepted_msg) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("module_global", "expected"), + [ + ({"CONSTANT": 1}, ["CONSTANT"]), + ({"_DEPRECATED_CONSTANT": 1}, ["_DEPRECATED_CONSTANT", "CONSTANT"]), + ( + {"_DEPRECATED_CONSTANT": 1, "SOMETHING": 2}, + ["_DEPRECATED_CONSTANT", "SOMETHING", "CONSTANT"], + ), + ], +) +def test_dir_with_deprecated_constants( + module_global: dict[str, Any], expected: list[str] +) -> None: + """Test dir() with deprecated constants.""" + assert dir_with_deprecated_constants(module_global) == expected From c226d793d429fade7bdda68f3ad132f9cb05906b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 19 Dec 2023 17:31:31 +0100 Subject: [PATCH 527/927] Move common function for testing derepcation constants to util (#106063) --- .../test_constant_deprecation/binary_sensor.py | 11 ++++++++--- .../test_constant_deprecation/util.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 tests/testing_config/custom_components/test_constant_deprecation/util.py diff --git a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py index e9e85dfb639..dda4d4f83f7 100644 --- a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py +++ b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py @@ -1,7 +1,12 @@ """Test deprecated binary sensor device classes.""" +from functools import partial + from homeassistant.components import binary_sensor +from .util import import_and_test_deprecated_costant -def import_deprecated(device_class: binary_sensor.BinarySensorDeviceClass): - """Import deprecated device class constant.""" - getattr(binary_sensor, f"DEVICE_CLASS_{device_class.name}") +import_deprecated = partial( + import_and_test_deprecated_costant, + module=binary_sensor, + constant_prefix="DEVICE_CLASS_", +) diff --git a/tests/testing_config/custom_components/test_constant_deprecation/util.py b/tests/testing_config/custom_components/test_constant_deprecation/util.py new file mode 100644 index 00000000000..126bf8a7359 --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/util.py @@ -0,0 +1,11 @@ +"""util module for test_constant_deprecation tests.""" + +from enum import Enum +from types import ModuleType + + +def import_and_test_deprecated_costant( + replacement: Enum, module: ModuleType, constant_prefix: str +) -> None: + """Import and test deprecated constant.""" + assert getattr(module, constant_prefix + replacement.name) == replacement From 91f8d3faef3517eb618999d826e658e60c2242d2 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Tue, 19 Dec 2023 18:07:27 +0100 Subject: [PATCH 528/927] Upgrade Prusa Link to new Digest Authentication and /v1/ API (#103396) Co-authored-by: Robert Resch --- CODEOWNERS | 4 +- .../components/prusalink/__init__.py | 110 ++++++++--- homeassistant/components/prusalink/button.py | 40 ++-- homeassistant/components/prusalink/camera.py | 10 +- .../components/prusalink/config_flow.py | 22 ++- .../components/prusalink/manifest.json | 4 +- homeassistant/components/prusalink/sensor.py | 97 ++++++---- .../components/prusalink/strings.json | 28 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/prusalink/conftest.py | 177 +++++++++++------- tests/components/prusalink/test_button.py | 10 +- tests/components/prusalink/test_camera.py | 4 +- .../components/prusalink/test_config_flow.py | 23 ++- tests/components/prusalink/test_init.py | 79 +++++++- tests/components/prusalink/test_sensor.py | 40 +++- 16 files changed, 466 insertions(+), 186 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9264561a0fc..b6c0e75e674 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -993,8 +993,8 @@ build.json @home-assistant/supervisor /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 /homeassistant/components/proxmoxve/ @jhollowe @Corbeno -/homeassistant/components/prusalink/ @balloob -/tests/components/prusalink/ @balloob +/homeassistant/components/prusalink/ @balloob @Skaronator +/tests/components/prusalink/ @balloob @Skaronator /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 /homeassistant/components/pure_energie/ @klaasnicolaas diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index e81901dad52..98dc7cb47ae 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -6,13 +6,21 @@ import asyncio from datetime import timedelta import logging from time import monotonic -from typing import Generic, TypeVar +from typing import TypeVar -from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import InvalidAuth, PrusaLinkError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -27,16 +35,71 @@ PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PrusaLink from a config entry.""" +async def _migrate_to_version_2( + hass: HomeAssistant, entry: ConfigEntry +) -> PrusaLink | None: + """Migrate to Version 2.""" + _LOGGER.debug("Migrating entry to version 2") + + data = dict(entry.data) + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + data = { + **entry.data, + CONF_USERNAME: "maker", + CONF_PASSWORD: entry.data[CONF_API_KEY], + } + data.pop(CONF_API_KEY) + api = PrusaLink( async_get_clientsession(hass), - entry.data["host"], - entry.data["api_key"], + data[CONF_HOST], + data[CONF_USERNAME], + data[CONF_PASSWORD], ) + try: + await api.get_info() + except InvalidAuth: + # We are unable to reach the new API which usually means + # that the user is running an outdated firmware version + ir.async_create_issue( + hass, + DOMAIN, + "firmware_5_1_required", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="firmware_5_1_required", + translation_placeholders={ + "entry_title": entry.title, + "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", + "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", + }, + ) + return None + + entry.version = 2 + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.info("Migrated config entry to version %d", entry.version) + return api + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PrusaLink from a config entry.""" + if entry.version == 1: + if (api := await _migrate_to_version_2(hass, entry)) is None: + return False + ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") + else: + api = PrusaLink( + async_get_clientsession(hass), + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) coordinators = { - "printer": PrinterUpdateCoordinator(hass, api), + "legacy_status": LegacyStatusCoordinator(hass, api), + "status": StatusCoordinator(hass, api), "job": JobUpdateCoordinator(hass, api), } for coordinator in coordinators.values(): @@ -49,6 +112,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + # Version 1->2 migration are handled in async_setup_entry. + 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): @@ -57,10 +126,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Update coordinator for the printer.""" config_entry: ConfigEntry @@ -105,21 +174,20 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): return timedelta(seconds=30) -class PrinterUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]): +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): """Printer update coordinator.""" - async def _fetch_data(self) -> PrinterInfo: + async def _fetch_data(self) -> PrinterStatus: """Fetch the printer data.""" - return await self.api.get_printer() + return await self.api.get_status() - def _get_update_interval(self, data: T) -> timedelta: - """Get new update interval.""" - if data and any( - data["state"]["flags"][key] for key in ("pausing", "cancelling") - ): - return timedelta(seconds=5) - return super()._get_update_interval(data) +class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): + """Printer legacy update coordinator.""" + + async def _fetch_data(self) -> LegacyPrinterStatus: + """Fetch the printer data.""" + return await self.api.get_legacy_printer() class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): @@ -142,5 +210,5 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=self.coordinator.config_entry.title, manufacturer="Prusa", - configuration_url=self.coordinator.api.host, + configuration_url=self.coordinator.api.client.host, ) diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index a44de101387..8f8a62794a9 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -5,7 +5,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic, TypeVar, cast -from pyprusalink import Conflict, JobInfo, PrinterInfo, PrusaLink +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import Conflict, PrinterState from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -15,14 +16,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) @dataclass(frozen=True) class PrusaLinkButtonEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" - press_fn: Callable[[PrusaLink], Coroutine[Any, Any, None]] + press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]] @dataclass(frozen=True) @@ -35,33 +36,34 @@ class PrusaLinkButtonEntityDescription( BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { - "printer": ( - PrusaLinkButtonEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="printer.cancel_job", translation_key="cancel_job", icon="mdi:cancel", - press_fn=lambda api: cast(Coroutine, api.cancel_job()), - available_fn=lambda data: any( - data["state"]["flags"][flag] - for flag in ("printing", "pausing", "paused") + press_fn=lambda api: api.cancel_job, + available_fn=lambda data: ( + data["printer"]["state"] + in [PrinterState.PRINTING.value, PrinterState.PAUSED.value] ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.pause_job", translation_key="pause_job", icon="mdi:pause", - press_fn=lambda api: cast(Coroutine, api.pause_job()), - available_fn=lambda data: ( - data["state"]["flags"]["printing"] - and not data["state"]["flags"]["paused"] + press_fn=lambda api: api.pause_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PRINTING.value ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.resume_job", translation_key="resume_job", icon="mdi:play", - press_fn=lambda api: cast(Coroutine, api.resume_job()), - available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]), + press_fn=lambda api: api.resume_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PAUSED.value + ), ), ), } @@ -113,8 +115,10 @@ class PrusaLinkButtonEntity(PrusaLinkEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" + job_id = self.coordinator.data["job"]["id"] + func = self.entity_description.press_fn(self.coordinator.api) try: - await self.entity_description.press_fn(self.coordinator.api) + await func(job_id) except Conflict as err: raise HomeAssistantError( "Action conflicts with current printer state" diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index a8b8f387eff..7f6fab0583b 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -35,7 +35,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): @property def available(self) -> bool: """Get if camera is available.""" - return super().available and self.coordinator.data.get("job") is not None + return ( + super().available + and (file := self.coordinator.data.get("file")) + and file.get("refs", {}).get("thumbnail") + ) async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -44,11 +48,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): if not self.available: return None - path = self.coordinator.data["job"]["file"]["path"] + path = self.coordinator.data["file"]["refs"]["thumbnail"] if self.last_path == path: return self.last_image - self.last_image = await self.coordinator.api.get_large_thumbnail(path) + self.last_image = await self.coordinator.api.get_file(path) self.last_path = path return self.last_image diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index b1faad6e3ea..e967cefaffd 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -7,11 +7,12 @@ from typing import Any from aiohttp import ClientError from awesomeversion import AwesomeVersion, AwesomeVersionException -from pyprusalink import InvalidAuth, PrusaLink +from pyprusalink import PrusaLink +from pyprusalink.types import InvalidAuth import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -25,7 +26,10 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Required(CONF_API_KEY): str, + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + vol.Required(CONF_USERNAME, default="maker"): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -35,7 +39,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) + api = PrusaLink( + async_get_clientsession(hass), + data[CONF_HOST], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) try: async with asyncio.timeout(5): @@ -57,7 +66,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PrusaLink.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -74,7 +83,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data = { CONF_HOST: host, - CONF_API_KEY: user_input[CONF_API_KEY], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], } errors = {} diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index ade39320a29..a9d8353690e 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "prusalink", "name": "PrusaLink", - "codeowners": ["@balloob"], + "codeowners": ["@balloob", "@Skaronator"], "config_flow": true, "dhcp": [ { @@ -10,5 +10,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/prusalink", "iot_class": "local_polling", - "requirements": ["pyprusalink==1.1.0"] + "requirements": ["pyprusalink==2.0.0"] } diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index c6feda0defd..29e1d5c9757 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -6,7 +6,8 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Generic, TypeVar, cast -from pyprusalink import JobInfo, PrinterInfo +from pyprusalink.types import JobInfo, PrinterState, PrinterStatus +from pyprusalink.types_legacy import LegacyPrinterStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,12 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfLength, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -24,7 +30,7 @@ from homeassistant.util.variance import ignore_variance from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) @dataclass(frozen=True) @@ -44,78 +50,91 @@ class PrusaLinkSensorEntityDescription( SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { - "printer": ( - PrusaLinkSensorEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.state", name=None, icon="mdi:printer-3d", - value_fn=lambda data: ( - "pausing" - if (flags := data["state"]["flags"])["pausing"] - else "cancelling" - if flags["cancelling"] - else "paused" - if flags["paused"] - else "printing" - if flags["printing"] - else "idle" - ), + value_fn=lambda data: (cast(str, data["printer"]["state"].lower())), device_class=SensorDeviceClass.ENUM, - options=["cancelling", "idle", "paused", "pausing", "printing"], + options=[state.value.lower() for state in PrinterState], translation_key="printer_state", ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed", translation_key="heatbed_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-bed"]), + value_fn=lambda data: cast(float, data["printer"]["temp_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle", translation_key="nozzle_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]), + value_fn=lambda data: cast(float, data["printer"]["temp_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed.target", translation_key="heatbed_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle.target", translation_key="nozzle_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.z-height", translation_key="z_height", native_unit_of_measurement=UnitOfLength.MILLIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["z-height"]), + value_fn=lambda data: cast(float, data["printer"]["axis_z"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.print-speed", translation_key="print_speed", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]), + value_fn=lambda data: cast(float, data["printer"]["speed"]), ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.print-flow", + translation_key="print_flow", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(float, data["printer"]["flow"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-hotend", + translation_key="fan_hotend", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_hotend"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-print", + translation_key="fan_print", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_print"]), + entity_registry_enabled_default=False, + ), + ), + "legacy_status": ( + PrusaLinkSensorEntityDescription[LegacyPrinterStatus]( key="printer.telemetry.material", translation_key="material", icon="mdi:palette-swatch-variant", @@ -128,15 +147,15 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { translation_key="progress", icon="mdi:progress-clock", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100, + value_fn=lambda data: cast(float, data["progress"]), available_fn=lambda data: data.get("progress") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", icon="mdi:file-image-outline", - value_fn=lambda data: cast(str, data["job"]["file"]["display"]), - available_fn=lambda data: data.get("job") is not None, + value_fn=lambda data: cast(str, data["file"]["display_name"]), + available_fn=lambda data: data.get("file") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", @@ -144,12 +163,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock-start", value_fn=ignore_variance( - lambda data: ( - utcnow() - timedelta(seconds=data["progress"]["printTime"]) - ), + lambda data: (utcnow() - timedelta(seconds=data["time_printing"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_printing") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", @@ -157,12 +174,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { icon="mdi:clock-end", device_class=SensorDeviceClass.TIMESTAMP, value_fn=ignore_variance( - lambda data: ( - utcnow() + timedelta(seconds=data["progress"]["printTimeLeft"]) - ), + lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_remaining") is not None, ), ), } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index aa992b4874f..bb32770e357 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -4,7 +4,8 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" } } }, @@ -15,15 +16,25 @@ "not_supported": "Only PrusaLink API v2 is supported" } }, + "issues": { + "firmware_5_1_required": { + "description": "The PrusaLink integration has been updated to utilize the latest v1 API endpoints, which require firmware version 4.7.0 or later. If you own a Prusa Mini, please make sure your printer is running firmware 5.1.0 or a more recent version, as firmware versions 4.7.x and 5.0.x are not available for this model.\n\nFollow the guide below to update your {entry_title}.\n* [Prusa Mini Firmware Update]({prusa_mini_firmware_update})\n* [Prusa MK4/XL Firmware Update]({prusa_mk4_xl_firmware_update})\n\nAfter you've updated your printer's firmware, make sure to reload the config entry to fix this issue.", + "title": "Firmware update required" + } + }, "entity": { "sensor": { "printer_state": { "state": { - "cancelling": "Cancelling", "idle": "[%key:common::state::idle%]", + "busy": "Busy", + "printing": "Printing", "paused": "[%key:common::state::paused%]", - "pausing": "Pausing", - "printing": "Printing" + "finished": "Finished", + "stopped": "Stopped", + "error": "Error", + "attention": "Attention", + "ready": "Ready" } }, "heatbed_temperature": { @@ -56,6 +67,15 @@ "print_speed": { "name": "Print speed" }, + "print_flow": { + "name": "Print flow" + }, + "fan_hotend": { + "name": "Hotend fan" + }, + "fan_print": { + "name": "Print fan" + }, "z_height": { "name": "Z-Height" } diff --git a/requirements_all.txt b/requirements_all.txt index 3d1d69eb5f8..6665cdfede4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2005,7 +2005,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d02ad7aad5..b31008f592f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1525,7 +1525,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 8beb67b0ed4..97f4bd92d7d 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -1,9 +1,10 @@ """Fixtures for PrusaLink.""" - from unittest.mock import patch import pytest +from homeassistant.components.prusalink import DOMAIN + from tests.common import MockConfigEntry @@ -11,7 +12,9 @@ from tests.common import MockConfigEntry def mock_config_entry(hass): """Mock a PrusaLink config entry.""" entry = MockConfigEntry( - domain="prusalink", data={"host": "http://example.com", "api_key": "abcdefgh"} + domain=DOMAIN, + data={"host": "http://example.com", "username": "dummy", "password": "dummypw"}, + version=2, ) entry.add_to_hass(hass) return entry @@ -23,96 +26,138 @@ def mock_version_api(hass): resp = { "api": "2.0.0", "server": "2.1.2", - "text": "PrusaLink MINI", - "hostname": "PrusaMINI", + "text": "PrusaLink", + "hostname": "PrusaXL", } with patch("pyprusalink.PrusaLink.get_version", return_value=resp): yield resp @pytest.fixture -def mock_printer_api(hass): +def mock_info_api(hass): + """Mock PrusaLink info API.""" + resp = { + "nozzle_diameter": 0.40, + "mmu": False, + "serial": "serial-1337", + "hostname": "PrusaXL", + "min_extrusion_temp": 170, + } + with patch("pyprusalink.PrusaLink.get_info", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_legacy_printer(hass): + """Mock PrusaLink printer API.""" + resp = {"telemetry": {"material": "PLA"}} + with patch("pyprusalink.PrusaLink.get_legacy_printer", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_idle(hass): """Mock PrusaLink printer API.""" resp = { - "telemetry": { - "temp-bed": 41.9, - "temp-nozzle": 47.8, - "print-speed": 100, - "z-height": 1.8, - "material": "PLA", + "storage": { + "path": "/usb/", + "name": "usb", + "read_only": False, }, - "temperature": { - "tool0": {"actual": 47.8, "target": 210.1, "display": 0.0, "offset": 0}, - "bed": {"actual": 41.9, "target": 60.5, "offset": 0}, - }, - "state": { - "text": "Operational", - "flags": { - "operational": True, - "paused": False, - "printing": False, - "cancelling": False, - "pausing": False, - "sdReady": False, - "error": False, - "closedOnError": False, - "ready": True, - "busy": False, - }, + "printer": { + "state": "IDLE", + "temp_bed": 41.9, + "target_bed": 60.5, + "temp_nozzle": 47.8, + "target_nozzle": 210.1, + "axis_z": 1.8, + "axis_x": 7.9, + "axis_y": 8.4, + "flow": 100, + "speed": 100, + "fan_hotend": 100, + "fan_print": 75, }, } - with patch("pyprusalink.PrusaLink.get_printer", return_value=resp): + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_printing(hass): + """Mock PrusaLink printer API.""" + resp = { + "job": { + "id": 129, + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + }, + "storage": {"path": "/usb/", "name": "usb", "read_only": False}, + "printer": { + "state": "PRINTING", + "temp_bed": 53.9, + "target_bed": 85.0, + "temp_nozzle": 6.0, + "target_nozzle": 0.0, + "axis_z": 5.0, + "flow": 100, + "speed": 100, + "fan_hotend": 5000, + "fan_print": 2500, + }, + } + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): yield resp @pytest.fixture def mock_job_api_idle(hass): """Mock PrusaLink job API having no job.""" + resp = {} + with patch("pyprusalink.PrusaLink.get_job", return_value=resp): + yield resp + + +@pytest.fixture +def mock_job_api_printing(hass): + """Mock PrusaLink printing.""" resp = { - "state": "Operational", - "job": None, - "progress": None, + "id": 129, + "state": "PRINTING", + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + "file": { + "refs": { + "icon": "/thumb/s/usb/TabletStand3~4.BGC", + "thumbnail": "/thumb/l/usb/TabletStand3~4.BGC", + "download": "/usb/TabletStand3~4.BGC", + }, + "name": "TabletStand3~4.BGC", + "display_name": "TabletStand3.bgcode", + "path": "/usb", + "size": 754535, + "m_timestamp": 1698686881, + }, } with patch("pyprusalink.PrusaLink.get_job", return_value=resp): yield resp @pytest.fixture -def mock_job_api_printing(hass, mock_printer_api, mock_job_api_idle): - """Mock PrusaLink printing.""" - mock_printer_api["state"]["text"] = "Printing" - mock_printer_api["state"]["flags"]["printing"] = True - - mock_job_api_idle.update( - { - "state": "Printing", - "job": { - "estimatedPrintTime": 117007, - "file": { - "name": "TabletStand3.gcode", - "path": "/usb/TABLET~1.GCO", - "display": "TabletStand3.gcode", - }, - }, - "progress": { - "completion": 0.37, - "printTime": 43987, - "printTimeLeft": 73020, - }, - } - ) - - -@pytest.fixture -def mock_job_api_paused(hass, mock_printer_api, mock_job_api_idle): +def mock_job_api_paused(hass, mock_get_status_printing, mock_job_api_printing): """Mock PrusaLink paused printing.""" - mock_printer_api["state"]["text"] = "Paused" - mock_printer_api["state"]["flags"]["printing"] = False - mock_printer_api["state"]["flags"]["paused"] = True - - mock_job_api_idle["state"] = "Paused" + mock_job_api_printing["state"] = "PAUSED" + mock_get_status_printing["printer"]["state"] = "PAUSED" @pytest.fixture -def mock_api(mock_version_api, mock_printer_api, mock_job_api_idle): +def mock_api( + mock_version_api, + mock_info_api, + mock_get_legacy_printer, + mock_get_status_idle, + mock_job_api_idle, +): """Mock PrusaLink API.""" diff --git a/tests/components/prusalink/test_button.py b/tests/components/prusalink/test_button.py index 658587327dd..5324e337780 100644 --- a/tests/components/prusalink/test_button.py +++ b/tests/components/prusalink/test_button.py @@ -1,7 +1,7 @@ """Test Prusalink buttons.""" from unittest.mock import patch -from pyprusalink import Conflict +from pyprusalink.types import Conflict import pytest from homeassistant.const import Platform @@ -32,6 +32,7 @@ async def test_button_pause_cancel( mock_api, hass_client: ClientSessionGenerator, mock_job_api_printing, + mock_get_status_printing, object_id, method, ) -> None: @@ -66,9 +67,12 @@ async def test_button_pause_cancel( @pytest.mark.parametrize( ("object_id", "method"), - (("mock_title_resume_job", "resume_job"),), + ( + ("mock_title_cancel_job", "cancel_job"), + ("mock_title_resume_job", "resume_job"), + ), ) -async def test_button_resume( +async def test_button_resume_cancel( hass: HomeAssistant, mock_config_entry, mock_api, diff --git a/tests/components/prusalink/test_camera.py b/tests/components/prusalink/test_camera.py index 010758bcca8..b84a13a3df8 100644 --- a/tests/components/prusalink/test_camera.py +++ b/tests/components/prusalink/test_camera.py @@ -49,13 +49,13 @@ async def test_camera_active_job( client = await hass_client() - with patch("pyprusalink.PrusaLink.get_large_thumbnail", return_value=b"hello"): + with patch("pyprusalink.PrusaLink.get_file", return_value=b"hello"): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" # Make sure we hit cached value. - with patch("pyprusalink.PrusaLink.get_large_thumbnail", side_effect=ValueError): + with patch("pyprusalink.PrusaLink.get_file", side_effect=ValueError): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" diff --git a/tests/components/prusalink/test_config_flow.py b/tests/components/prusalink/test_config_flow.py index 4810ea82166..6a23e05adf9 100644 --- a/tests/components/prusalink/test_config_flow.py +++ b/tests/components/prusalink/test_config_flow.py @@ -25,16 +25,18 @@ async def test_form(hass: HomeAssistant, mock_version_api) -> None: result["flow_id"], { "host": "http://1.1.1.1/", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "PrusaMINI" + assert result2["title"] == "PrusaXL" assert result2["data"] == { "host": "http://1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", } assert len(mock_setup_entry.mock_calls) == 1 @@ -53,7 +55,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -75,7 +78,8 @@ async def test_form_unknown(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -95,7 +99,8 @@ async def test_form_too_low_version(hass: HomeAssistant, mock_version_api) -> No result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -115,7 +120,8 @@ async def test_form_invalid_version_2(hass: HomeAssistant, mock_version_api) -> result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -137,7 +143,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) diff --git a/tests/components/prusalink/test_init.py b/tests/components/prusalink/test_init.py index 543ee68d5dd..963750ef8be 100644 --- a/tests/components/prusalink/test_init.py +++ b/tests/components/prusalink/test_init.py @@ -2,14 +2,17 @@ from datetime import timedelta from unittest.mock import patch -from pyprusalink import InvalidAuth, PrusaLinkError +from pyprusalink.types import InvalidAuth, PrusaLinkError import pytest +from homeassistant.components.prusalink import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_unloading( @@ -39,7 +42,13 @@ async def test_failed_update( assert mock_config_entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.prusalink.PrusaLink.get_printer", + "homeassistant.components.prusalink.PrusaLink.get_version", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_status", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_legacy_printer", side_effect=exception, ), patch( "homeassistant.components.prusalink.PrusaLink.get_job", @@ -50,3 +59,67 @@ async def test_failed_update( for state in hass.states.async_all(): assert state.state == "unavailable" + + +async def test_migration_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +) -> None: + """Test migrating from version 1 to 2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + }, + version=1, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + # Ensure that we have username, password after migration + assert len(config_entries) == 1 + assert config_entries[0].data == { + CONF_HOST: "http://prusaxl.local", + CONF_USERNAME: "maker", + CONF_PASSWORD: "api-key", + } + # Make sure that we don't have any issues + assert len(issue_registry.issues) == 0 + + +async def test_outdated_firmware_migration_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +) -> None: + """Test migrating from version 1 to 2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + }, + version=1, + ) + entry.add_to_hass(hass) + + with patch( + "pyprusalink.PrusaLink.get_info", + side_effect=InvalidAuth, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.SETUP_ERROR + # Make sure that we don't have thrown the issues + assert len(issue_registry.issues) == 1 + + # Reloading the integration with a working API (e.g. User updated firmware) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Integration should be running now, the issue should be gone + assert entry.state == ConfigEntryState.LOADED + assert len(issue_registry.issues) == 0 diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 0f2a966b4e4..366f2d3abc8 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, Platform, UnitOfLength, UnitOfTemperature, @@ -44,11 +45,15 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "idle" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == [ - "cancelling", "idle", - "paused", - "pausing", + "busy", "printing", + "paused", + "finished", + "stopped", + "error", + "attention", + "ready", ] state = hass.states.get("sensor.mock_title_heatbed_temperature") @@ -95,6 +100,11 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state is not None assert state.state == "PLA" + state = hass.states.get("sensor.mock_title_print_flow") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + state = hass.states.get("sensor.mock_title_progress") assert state is not None assert state.state == "unavailable" @@ -114,12 +124,22 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "unavailable" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "75" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry, mock_api, - mock_printer_api, + mock_get_status_printing, mock_job_api_printing, ) -> None: """Test sensors while active job.""" @@ -140,7 +160,7 @@ async def test_sensors_active_job( state = hass.states.get("sensor.mock_title_filename") assert state is not None - assert state.state == "TabletStand3.gcode" + assert state.state == "TabletStand3.bgcode" state = hass.states.get("sensor.mock_title_print_start") assert state is not None @@ -151,3 +171,13 @@ async def test_sensors_active_job( assert state is not None assert state.state == "2022-08-28T10:17:00+00:00" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "5000" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "2500" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE From 9275d35c0aa3f747bc4250a1a271c301fcbe5412 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 19 Dec 2023 19:02:40 +0100 Subject: [PATCH 529/927] Improve tests for easyEnergy (#105989) --- .../components/easyenergy/services.py | 3 +- .../easyenergy/snapshots/test_services.ambr | 1818 ++++++++++++++++- tests/components/easyenergy/test_services.py | 77 +- 3 files changed, 1864 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 4aeef2f4d71..777fa4280b2 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -31,9 +31,9 @@ ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" SERVICE_SCHEMA: Final = vol.Schema( { + vol.Required(ATTR_INCL_VAT): bool, vol.Optional(ATTR_START): str, vol.Optional(ATTR_END): str, - vol.Required(ATTR_INCL_VAT, default=True): bool, } ) @@ -55,6 +55,7 @@ def __get_date(date_input: str | None) -> date | datetime: return value raise ServiceValidationError( + "Invalid datetime provided.", translation_domain=DOMAIN, translation_key="invalid_date", translation_placeholders={ diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr index c878709a997..96b1eca5498 100644 --- a/tests/components/easyenergy/snapshots/test_services.ambr +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -918,22 +918,616 @@ }) # --- # name: test_service[end0-start1-incl_vat0-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat0-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat0-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat1-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat1-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat1-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end0-start1-incl_vat2-get_energy_return_prices] ServiceValidationError() @@ -1863,22 +2457,616 @@ }) # --- # name: test_service[end1-start0-incl_vat0-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat0-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat0-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat1-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat1-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat1-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start0-incl_vat2-get_energy_return_prices] ServiceValidationError() @@ -1890,22 +3078,616 @@ ServiceValidationError() # --- # name: test_service[end1-start1-incl_vat0-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat0-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat0-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat1-get_energy_return_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat1-get_energy_usage_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat1-get_gas_prices] - ServiceValidationError() + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) # --- # name: test_service[end1-start1-incl_vat2-get_energy_return_prices] ServiceValidationError() diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py index 24bee929489..d47b86e93a3 100644 --- a/tests/components/easyenergy/test_services.py +++ b/tests/components/easyenergy/test_services.py @@ -2,6 +2,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.components.easyenergy.services import ( @@ -25,15 +26,16 @@ async def test_has_services( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( - "service", [GAS_SERVICE_NAME, ENERGY_USAGE_SERVICE_NAME, ENERGY_RETURN_SERVICE_NAME] -) -@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}, {}]) -@pytest.mark.parametrize( - "start", [{"start": "2023-01-01 00:00:00"}, {"start": "incorrect date"}, {}] -) -@pytest.mark.parametrize( - "end", [{"end": "2023-01-01 00:00:00"}, {"end": "incorrect date"}, {}] + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], ) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) async def test_service( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -42,18 +44,63 @@ async def test_service( start: dict[str, str], end: dict[str, str], ) -> None: - """Test the easyEnergy Service.""" + """Test the EnergyZero Service.""" data = incl_vat | start | end - try: - response = await hass.services.async_call( + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize( + ("service_data", "error", "error_message"), + [ + ({}, vol.er.Error, "required key not provided .+"), + ( + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"incl_vat": True, "start": "incorrect date"}, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"incl_vat": True, "end": "incorrect date"}, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + service_data: dict[str, str | bool], + error: type[Exception], + error_message: str, +) -> None: + """Test the easyEnergy Service.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( DOMAIN, service, - data, + service_data, blocking=True, return_response=True, ) - assert response == snapshot - except ServiceValidationError as e: - assert e == snapshot From db985925c47697f391253d31b9ecfa363a1f9f6d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 19 Dec 2023 19:22:13 +0100 Subject: [PATCH 530/927] Deprecate deprecated automation constants (#106067) --- .../components/automation/__init__.py | 23 +++++++-- tests/common.py | 47 +++++++++++++++++-- tests/components/automation/test_init.py | 22 +++++++++ tests/components/binary_sensor/test_init.py | 8 +--- .../test_constant_deprecation/__init__.py | 9 ++++ .../binary_sensor.py | 12 ----- .../test_constant_deprecation/util.py | 11 ----- 7 files changed, 95 insertions(+), 37 deletions(-) create mode 100644 tests/testing_config/custom_components/test_constant_deprecation/__init__.py delete mode 100644 tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py delete mode 100644 tests/testing_config/custom_components/test_constant_deprecation/util.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 84f7f3aca52..4e6fa477ed2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass +from functools import partial import logging from typing import Any, Protocol, cast @@ -55,6 +56,11 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -130,9 +136,20 @@ class IfAction(Protocol): # AutomationActionType, AutomationTriggerData, # and AutomationTriggerInfo are deprecated as of 2022.9. -AutomationActionType = TriggerActionType -AutomationTriggerData = TriggerData -AutomationTriggerInfo = TriggerInfo +# Can be removed in 2025.1 +_DEPRECATED_AutomationActionType = DeprecatedConstant( + TriggerActionType, "TriggerActionType", "2025.1" +) +_DEPRECATED_AutomationTriggerData = DeprecatedConstant( + TriggerData, "TriggerData", "2025.1" +) +_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant( + TriggerInfo, "TriggerInfo", "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) @bind_hass diff --git a/tests/common.py b/tests/common.py index 05bddec203c..c402f2aa661 100644 --- a/tests/common.py +++ b/tests/common.py @@ -91,6 +91,10 @@ from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader +from tests.testing_config.custom_components.test_constant_deprecation import ( + import_deprecated_costant, +) + _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -1465,24 +1469,57 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) -def validate_deprecated_constant( +def import_and_test_deprecated_constant_enum( caplog: pytest.LogCaptureFixture, module: ModuleType, replacement: Enum, constant_prefix: str, breaks_in_ha_version: str, ) -> None: - """Validate deprecated constant creates a log entry and is included in the modules.__dir__().""" + """Import and test deprecated constant replaced by a enum. + + - Import deprecated enum + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + import_and_test_deprecated_constant( + caplog, + module, + constant_prefix + replacement.name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + breaks_in_ha_version, + ) + + +def import_and_test_deprecated_constant( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + constant_name: str, + replacement_name: str, + replacement: Any, + breaks_in_ha_version: str, +) -> None: + """Import and test deprecated constant replaced by a value. + + - Import deprecated constant + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + value = import_deprecated_costant(module, constant_name) + assert value == replacement assert ( module.__name__, logging.WARNING, ( - f"{constant_prefix}{replacement.name} was used from test_constant_deprecation," + f"{constant_name} was used from test_constant_deprecation," f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " - f"Use {replacement.__class__.__name__}.{replacement.name} instead, please report " + f"Use {replacement_name} instead, please report " "it to the author of the 'test_constant_deprecation' custom integration" ), ) in caplog.record_tuples # verify deprecated constant is included in dir() - assert f"{constant_prefix}{replacement.name}" in dir(module) + assert constant_name in dir(module) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 359303c51fd..a2f2dfbf907 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import Mock, patch import pytest @@ -46,6 +47,7 @@ from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, _async_stop_scripts_at_shutdown, ) +from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.setup import async_setup_component from homeassistant.util import yaml import homeassistant.util.dt as dt_util @@ -57,6 +59,7 @@ from tests.common import ( async_capture_events, async_fire_time_changed, async_mock_service, + import_and_test_deprecated_constant, mock_restore_cache, ) from tests.components.logbook.common import MockRow, mock_humanify @@ -2564,3 +2567,22 @@ async def test_websocket_config( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "not_found" + + +@pytest.mark.parametrize( + ("constant_name", "replacement"), + [ + ("AutomationActionType", TriggerActionType), + ("AutomationTriggerData", TriggerData), + ("AutomationTriggerInfo", TriggerInfo), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement: Any, +) -> None: + """Test deprecated binary sensor device classes.""" + import_and_test_deprecated_constant( + caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" + ) diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index ac957818be9..014722d94a4 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -14,15 +14,12 @@ from tests.common import ( MockConfigEntry, MockModule, MockPlatform, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, - validate_deprecated_constant, ) from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor -from tests.testing_config.custom_components.test_constant_deprecation.binary_sensor import ( - import_deprecated, -) TEST_DOMAIN = "test" @@ -209,7 +206,6 @@ def test_deprecated_constant_device_class( device_class: binary_sensor.BinarySensorDeviceClass, ) -> None: """Test deprecated binary sensor device classes.""" - import_deprecated(device_class) - validate_deprecated_constant( + import_and_test_deprecated_constant_enum( caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1" ) diff --git a/tests/testing_config/custom_components/test_constant_deprecation/__init__.py b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py new file mode 100644 index 00000000000..4367cbed7b1 --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py @@ -0,0 +1,9 @@ +"""Test deprecated constants custom integration.""" + +from types import ModuleType +from typing import Any + + +def import_deprecated_costant(module: ModuleType, constant_name: str) -> Any: + """Import and return deprecated constant.""" + return getattr(module, constant_name) diff --git a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py deleted file mode 100644 index dda4d4f83f7..00000000000 --- a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Test deprecated binary sensor device classes.""" -from functools import partial - -from homeassistant.components import binary_sensor - -from .util import import_and_test_deprecated_costant - -import_deprecated = partial( - import_and_test_deprecated_costant, - module=binary_sensor, - constant_prefix="DEVICE_CLASS_", -) diff --git a/tests/testing_config/custom_components/test_constant_deprecation/util.py b/tests/testing_config/custom_components/test_constant_deprecation/util.py deleted file mode 100644 index 126bf8a7359..00000000000 --- a/tests/testing_config/custom_components/test_constant_deprecation/util.py +++ /dev/null @@ -1,11 +0,0 @@ -"""util module for test_constant_deprecation tests.""" - -from enum import Enum -from types import ModuleType - - -def import_and_test_deprecated_costant( - replacement: Enum, module: ModuleType, constant_prefix: str -) -> None: - """Import and test deprecated constant.""" - assert getattr(module, constant_prefix + replacement.name) == replacement From 1ae6f1e9e2495d1df2d5071d3ca8be9365644495 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 19 Dec 2023 20:16:18 +0100 Subject: [PATCH 531/927] Add valve support to switch_as_x (#105988) --- .../components/switch_as_x/config_flow.py | 1 + homeassistant/components/switch_as_x/valve.py | 91 +++++++++++++ .../switch_as_x/test_config_flow.py | 1 + tests/components/switch_as_x/test_init.py | 2 + tests/components/switch_as_x/test_valve.py | 122 ++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 homeassistant/components/switch_as_x/valve.py create mode 100644 tests/components/switch_as_x/test_valve.py diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 8b6527eb49e..90f6b985893 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -22,6 +22,7 @@ TARGET_DOMAIN_OPTIONS = [ selector.SelectOptionDict(value=Platform.LIGHT, label="Light"), selector.SelectOptionDict(value=Platform.LOCK, label="Lock"), selector.SelectOptionDict(value=Platform.SIREN, label="Siren"), + selector.SelectOptionDict(value=Platform.VALVE, label="Valve"), ] CONFIG_FLOW = { diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py new file mode 100644 index 00000000000..3a9fbc16247 --- /dev/null +++ b/homeassistant/components/switch_as_x/valve.py @@ -0,0 +1,91 @@ +"""Valve support for switch entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import EventType + +from .entity import BaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Valve Switch config entry.""" + registry = er.async_get(hass) + entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + + async_add_entities( + [ + ValveSwitch( + hass, + config_entry.title, + VALVE_DOMAIN, + entity_id, + config_entry.entry_id, + ) + ] + ) + + +class ValveSwitch(BaseEntity, ValveEntity): + """Represents a Switch as a Valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + @callback + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: + """Handle child updates.""" + super().async_state_changed_listener(event) + if ( + not self.available + or (state := self.hass.states.get(self._switch_entity_id)) is None + ): + return + + self._attr_is_closed = state.state != STATE_ON diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 412cbc4333b..51efbf99892 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,6 +20,7 @@ PLATFORMS_TO_TEST = ( Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a0c0bfca825..738127faf43 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -36,6 +36,7 @@ PLATFORMS_TO_TEST = ( Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) @@ -72,6 +73,7 @@ async def test_config_entry_unregistered_uuid( (Platform.LIGHT, STATE_ON, STATE_OFF), (Platform.LOCK, STATE_UNLOCKED, STATE_LOCKED), (Platform.SIREN, STATE_ON, STATE_OFF), + (Platform.VALVE, STATE_OPEN, STATE_CLOSED), ), ) async def test_entity_registry_events( diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py new file mode 100644 index 00000000000..da20c544f64 --- /dev/null +++ b/tests/components/switch_as_x/test_valve.py @@ -0,0 +1,122 @@ +"""Tests for the Switch as X Valve platform.""" +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + CONF_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_CLOSED, + STATE_OFF, + STATE_ON, + STATE_OPEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test valve switch default state.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Garage Door", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("valve.garage_door") + assert state is not None + assert state.state == "unavailable" + assert state.attributes["supported_features"] == 3 + + +async def test_service_calls(hass: HomeAssistant) -> None: + """Test service calls to valve.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Title is ignored", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN From 64a2c64419074b5f8f89b3dfd79345c62ffc732c Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Wed, 20 Dec 2023 06:12:30 +1000 Subject: [PATCH 532/927] Bump async-upnp-client to 0.38.0 (#105980) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/device.py | 4 ++-- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 0fa884319c4..03c9942968c 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b3fa91a2e70..6173c9a3843 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.36.2"], + "requirements": ["async-upnp-client==0.38.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 48bdb7083b4..6f5defe4c57 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.2" + "async-upnp-client==0.38.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index bf48b44e5dc..e6f18190c0b 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.36.2"] + "requirements": ["async-upnp-client==0.38.0"] } diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 93f551bea37..2f52a5d008f 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -157,7 +157,7 @@ class Device: _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() status_info = igd_state.status_info - if status_info is not None and not isinstance(status_info, Exception): + if status_info is not None and not isinstance(status_info, BaseException): wan_status = status_info.connection_status router_uptime = status_info.uptime else: @@ -165,7 +165,7 @@ class Device: router_uptime = None def get_value(value: Any) -> Any: - if value is None or isinstance(value, Exception): + if value is None or isinstance(value, BaseException): return None return value diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 25f83e0dbf5..4c3fdb65809 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index b3bc0c30bf4..4881d8c576d 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.36.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 88f7937bb12..1b6e8ba3db4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiohttp-zlib-ng==0.1.1 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6665cdfede4..816a94e2dca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -469,7 +469,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b31008f592f..00ad7fba8b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -421,7 +421,7 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.sleepiq asyncsleepiq==1.4.0 From 63a535e9d9dbc1cf03f0e3cce471a57ee817728d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Dec 2023 21:17:29 +0100 Subject: [PATCH 533/927] Small cleanup in Tailwind (#106073) --- .../components/tailwind/binary_sensor.py | 15 +- homeassistant/components/tailwind/button.py | 10 -- homeassistant/components/tailwind/cover.py | 9 -- homeassistant/components/tailwind/entity.py | 24 ++- homeassistant/components/tailwind/number.py | 10 -- .../snapshots/test_binary_sensor.ambr | 144 ++++++++++++++++++ .../components/tailwind/test_binary_sensor.py | 26 ++-- 7 files changed, 181 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 7eec74042e2..13a987bb998 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -46,7 +46,7 @@ async def async_setup_entry( """Set up Tailwind binary sensor based on a config entry.""" coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TailwindDoorBinarySensorEntity(coordinator, description, door_id) + TailwindDoorBinarySensorEntity(coordinator, door_id, description) for description in DESCRIPTIONS for door_id in coordinator.data.doors ) @@ -57,19 +57,6 @@ class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): entity_description: TailwindDoorBinarySensorEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindDoorBinarySensorEntityDescription, - door_id: str, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator, door_id) - self.entity_description = description - self._attr_unique_id = ( - f"{coordinator.data.device_id}-{door_id}-{description.key}" - ) - @property def is_on(self) -> bool | None: """Return the state of the binary sensor.""" diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index e66a95f3ac4..dd9548d131c 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -60,16 +60,6 @@ class TailwindButtonEntity(TailwindEntity, ButtonEntity): entity_description: TailwindButtonEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindButtonEntityDescription, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator=coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" - async def async_press(self) -> None: """Trigger button press on the Tailwind device.""" await self.entity_description.press_fn(self.coordinator.tailwind) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 5a1f9cb8d73..4280b6c4baf 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -41,15 +41,6 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): _attr_name = None _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - door_id: str, - ) -> None: - """Initiate Tailwind button entity.""" - super().__init__(coordinator, door_id) - self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" - @property def is_closed(self) -> bool: """Return if the cover is closed or not.""" diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index e4b18d5e4da..a97d74490dc 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -13,9 +14,15 @@ class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: TailwindDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: """Initialize an Tailwind entity.""" super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.device_id}-{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data.device_id)}, connections={(CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, @@ -35,11 +42,22 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): _attr_has_entity_name = True def __init__( - self, coordinator: TailwindDataUpdateCoordinator, door_id: str + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + entity_description: EntityDescription | None = None, ) -> None: """Initialize an Tailwind door entity.""" - self.door_id = door_id super().__init__(coordinator) + self.door_id = door_id + + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + if entity_description: + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 3d932939ba4..88940d110fa 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -65,16 +65,6 @@ class TailwindNumberEntity(TailwindEntity, NumberEntity): entity_description: TailwindNumberEntityDescription - def __init__( - self, - coordinator: TailwindDataUpdateCoordinator, - description: TailwindNumberEntityDescription, - ) -> None: - """Initiate Tailwind number entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" - @property def native_value(self) -> int | None: """Return the number value.""" diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 18145d0274e..e3da11f28d1 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -143,3 +143,147 @@ 'via_device_id': None, }) # --- +# name: test_number_entities[binary_sensor.door_1_operational_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 1 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 2 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py index 1a8269e8457..8715c143628 100644 --- a/tests/components/tailwind/test_binary_sensor.py +++ b/tests/components/tailwind/test_binary_sensor.py @@ -9,23 +9,27 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.door_1_operational_status", + "binary_sensor.door_2_operational_status", + ], +) async def test_number_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test binary sensor entities provided by the Tailwind integration.""" - for entity_id in ( - "binary_sensor.door_1_operational_status", - "binary_sensor.door_2_operational_status", - ): - assert (state := hass.states.get(entity_id)) - assert snapshot == state + assert (state := hass.states.get(entity_id)) + assert snapshot == state - assert (entity_entry := entity_registry.async_get(state.entity_id)) - assert snapshot == entity_entry + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert snapshot == device_entry + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry From f7a5b14cd640af61a714c7747a87f53fefc6976d Mon Sep 17 00:00:00 2001 From: raww Date: Tue, 19 Dec 2023 20:27:31 +0000 Subject: [PATCH 534/927] Bump switchbot-api to 1.3.0 (#105594) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 9a4e4fbe196..1539c81331e 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.2.1"] + "requirements": ["switchbot-api==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 816a94e2dca..c547318015d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2567,7 +2567,7 @@ surepy==0.8.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00ad7fba8b3..fd4979a8202 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1928,7 +1928,7 @@ sunweg==2.0.1 surepy==0.8.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.system_bridge systembridgeconnector==3.10.0 From c5a3e58994455cfbd471871a75b3c95c5fef9ea0 Mon Sep 17 00:00:00 2001 From: vexofp Date: Tue, 19 Dec 2023 15:47:42 -0500 Subject: [PATCH 535/927] Pass timeout to httpx in RESTful Switch (#105364) Co-authored-by: J. Nick Koston --- homeassistant/components/rest/switch.py | 36 ++++++++++++------------- tests/components/rest/test_switch.py | 11 ++++---- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index f80143e2f9e..7dbe295afee 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -202,22 +202,22 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req: httpx.Response = await getattr(websession, self._method)( - self._resource, - auth=self._auth, - content=bytes(body, "utf-8"), - headers=rendered_headers, - params=rendered_params, - ) - return req + req: httpx.Response = await getattr(websession, self._method)( + self._resource, + auth=self._auth, + content=bytes(body, "utf-8"), + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + return req async def async_update(self) -> None: """Get the current state, catching errors.""" req = None try: req = await self.get_device_state(self.hass) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) @@ -233,14 +233,14 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req = await websession.get( - self._state_resource, - auth=self._auth, - headers=rendered_headers, - params=rendered_params, - ) - text = req.text + req = await websession.get( + self._state_resource, + auth=self._auth, + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 6224d98f694..7be2ce4c63e 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -1,5 +1,4 @@ """The tests for the REST switch platform.""" -import asyncio from http import HTTPStatus import httpx @@ -103,7 +102,7 @@ async def test_setup_failed_connect( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection error occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.ConnectError("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -117,7 +116,7 @@ async def test_setup_timeout( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection timeout occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -326,7 +325,7 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: """Test turn_on when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -389,7 +388,7 @@ async def test_turn_off_timeout(hass: HomeAssistant) -> None: """Test turn_off when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -442,7 +441,7 @@ async def test_update_timeout(hass: HomeAssistant) -> None: """Test update when timeout occurs.""" await _async_setup_test_switch(hass) - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() From 97a14f6b6c6f64d1fee829774b99606f7c9263f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Dec 2023 11:43:00 -1000 Subject: [PATCH 536/927] Bump aioesphomeapi to 21.0.1 (#106079) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 770040746bb..4a1301ccf29 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.0", + "aioesphomeapi==21.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==0.4.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c547318015d..021a8d8b131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd4979a8202..d91b576eb31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 918ea8f56d14af7565500e4d210e6bb96b6efae5 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 19 Dec 2023 23:19:24 +0100 Subject: [PATCH 537/927] Move shared enigma2 constants (#106064) enigma2: move shared constants from media_player.py into its own const.py --- homeassistant/components/enigma2/const.py | 17 ++++++++++ .../components/enigma2/media_player.py | 31 ++++++++++--------- 2 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/enigma2/const.py diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py new file mode 100644 index 00000000000..0511a794172 --- /dev/null +++ b/homeassistant/components/enigma2/const.py @@ -0,0 +1,17 @@ +"""Constants for the Enigma2 platform.""" +DOMAIN = "enigma2" + +CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_SOURCE_BOUQUET = "source_bouquet" +CONF_MAC_ADDRESS = "mac_address" + +DEFAULT_NAME = "Enigma2 Media Player" +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "dreambox" +DEFAULT_DEEP_STANDBY = False +DEFAULT_SOURCE_BOUQUET = "" +DEFAULT_MAC_ADDRESS = "" diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 345ba1f8acb..8e24caf1b08 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -24,26 +24,27 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_MAC_ADDRESS, + DEFAULT_NAME, + DEFAULT_PASSWORD, + DEFAULT_PORT, + DEFAULT_SOURCE_BOUQUET, + DEFAULT_SSL, + DEFAULT_USE_CHANNEL_ICON, + DEFAULT_USERNAME, +) + ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_MEDIA_END_TIME = "media_end_time" ATTR_MEDIA_START_TIME = "media_start_time" -CONF_USE_CHANNEL_ICON = "use_channel_icon" -CONF_DEEP_STANDBY = "deep_standby" -CONF_MAC_ADDRESS = "mac_address" -CONF_SOURCE_BOUQUET = "source_bouquet" - -DEFAULT_NAME = "Enigma2 Media Player" -DEFAULT_PORT = 80 -DEFAULT_SSL = False -DEFAULT_USE_CHANNEL_ICON = False -DEFAULT_USERNAME = "root" -DEFAULT_PASSWORD = "dreambox" -DEFAULT_DEEP_STANDBY = False -DEFAULT_MAC_ADDRESS = "" -DEFAULT_SOURCE_BOUQUET = "" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, From c7f6ab2457a9e6f794ae89951c7d787b048bcb8e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 07:49:49 +0100 Subject: [PATCH 538/927] Add MQTT valve platform (#105766) * Add mqtt valve platform * No stop topic-reports_position and validation * Do not allow state_open, state_closed with position reporing valve * Allow open/close feature to be disabled * Follow up comments * Rename * Apply defaults in validator * Update docstr --- .../components/mqtt/abbreviations.py | 1 + .../components/mqtt/config_integration.py | 1 + homeassistant/components/mqtt/const.py | 16 + homeassistant/components/mqtt/cover.py | 32 +- homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/valve.py | 420 +++++ tests/components/mqtt/test_valve.py | 1399 +++++++++++++++++ 7 files changed, 1855 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/mqtt/valve.py create mode 100644 tests/components/mqtt/test_valve.py diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index eb9ab56208e..64d8c27f1de 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -166,6 +166,7 @@ ABBREVIATIONS = { "pl_ton": "payload_turn_on", "pl_trig": "payload_trigger", "pl_unlk": "payload_unlock", + "pos": "reports_position", "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 71260dc0239..0f2d617930d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -53,6 +53,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), + Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 685e45700b5..50ea3860d9e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -42,9 +42,18 @@ CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_PAYLOAD_CLOSE = "payload_close" +CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_STOP = "payload_stop" +CONF_POSITION_CLOSED = "position_closed" +CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_STATE_CLOSED = "state_closed" +CONF_STATE_CLOSING = "state_closing" +CONF_STATE_OPEN = "state_open" +CONF_STATE_OPENING = "state_opening" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -81,11 +90,16 @@ DEFAULT_ENCODING = "utf-8" DEFAULT_OPTIMISTIC = False DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" +DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" +DEFAULT_POSITION_CLOSED = 0 +DEFAULT_POSITION_OPEN = 100 +DEFAULT_RETAIN = False PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" @@ -146,6 +160,7 @@ PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] @@ -173,5 +188,6 @@ RELOADABLE_PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 4e8cf0f4129..912de7e367b 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -38,10 +38,24 @@ from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, CONF_QOS, CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, ) from .debug_info import log_messages from .mixins import ( @@ -64,15 +78,6 @@ CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" -CONF_PAYLOAD_CLOSE = "payload_close" -CONF_PAYLOAD_OPEN = "payload_open" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_POSITION_CLOSED = "position_closed" -CONF_POSITION_OPEN = "position_open" -CONF_STATE_CLOSED = "state_closed" -CONF_STATE_CLOSING = "state_closing" -CONF_STATE_OPEN = "state_open" -CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" @@ -84,13 +89,10 @@ TILT_PAYLOAD = "tilt" COVER_PAYLOAD = "cover" DEFAULT_NAME = "MQTT Cover" -DEFAULT_PAYLOAD_CLOSE = "CLOSE" -DEFAULT_PAYLOAD_OPEN = "OPEN" -DEFAULT_PAYLOAD_STOP = "STOP" -DEFAULT_POSITION_CLOSED = 0 -DEFAULT_POSITION_OPEN = 100 -DEFAULT_RETAIN = False + DEFAULT_STATE_STOPPED = "stopped" +DEFAULT_PAYLOAD_STOP = "STOP" + DEFAULT_TILT_CLOSED_POSITION = 0 DEFAULT_TILT_MAX = 100 DEFAULT_TILT_MIN = 0 diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c78319bb46a..84163e217df 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -74,6 +74,7 @@ SUPPORTED_COMPONENTS = { "text", "update", "vacuum", + "valve", "water_heater", } diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py new file mode 100644 index 00000000000..2c1618c60ba --- /dev/null +++ b/homeassistant/components/mqtt/valve.py @@ -0,0 +1,420 @@ +"""Support for MQTT valve devices.""" +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import valve +from homeassistant.components.valve import ( + DEVICE_CLASSES_SCHEMA, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_OPTIMISTIC, + CONF_VALUE_TEMPLATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATE_TOPIC, + DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, + write_state_on_attr_change, +) +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + ReceiveMessage, + ReceivePayloadType, +) +from .util import valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORTS_POSITION = "reports_position" + +DEFAULT_NAME = "MQTT Valve" + +MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset( + { + valve.ATTR_CURRENT_POSITION, + } +) + +NO_POSITION_KEYS = ( + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_STATE_CLOSED, + CONF_STATE_OPEN, +) + +DEFAULTS = { + CONF_PAYLOAD_CLOSE: DEFAULT_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN: DEFAULT_PAYLOAD_OPEN, + CONF_STATE_OPEN: STATE_OPEN, + CONF_STATE_CLOSED: STATE_CLOSED, +} + + +def _validate_and_add_defaults(config: ConfigType) -> ConfigType: + """Validate config options and set defaults.""" + if config[CONF_REPORTS_POSITION] and any(key in config for key in NO_POSITION_KEYS): + raise vol.Invalid( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." + ) + return {**DEFAULTS, **config} + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None), + vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int, + vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int, + vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_CLOSED): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, + vol.Optional(CONF_STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, _validate_and_add_defaults) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), + _validate_and_add_defaults, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT valve through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttValve, + valve.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttValve(MqttEntity, ValveEntity): + """Representation of a valve that can be controlled using MQTT.""" + + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_VALVE_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format: str = valve.ENTITY_ID_FORMAT + _optimistic: bool + _range: tuple[int, int] + _tilt_optimistic: bool + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """Set up valve from config.""" + self._attr_reports_position = config[CONF_REPORTS_POSITION] + self._range = ( + self._config[CONF_POSITION_CLOSED] + 1, + self._config[CONF_POSITION_OPEN], + ) + no_state_topic = config.get(CONF_STATE_TOPIC) is None + self._optimistic = config[CONF_OPTIMISTIC] or no_state_topic + self._attr_assumed_state = self._optimistic + + template_config_attributes = { + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + } + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + config_attributes=template_config_attributes, + ).async_render_with_possible_json_value + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + supported_features = ValveEntityFeature(0) + if CONF_COMMAND_TOPIC in config: + if config[CONF_PAYLOAD_OPEN] is not None: + supported_features |= ValveEntityFeature.OPEN + if config[CONF_PAYLOAD_CLOSE] is not None: + supported_features |= ValveEntityFeature.CLOSE + + if config[CONF_REPORTS_POSITION]: + supported_features |= ValveEntityFeature.SET_POSITION + if config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= ValveEntityFeature.STOP + + self._attr_supported_features = supported_features + + @callback + def _update_state(self, state: str) -> None: + """Update the valve state based on static payload.""" + self._attr_is_closed = state == STATE_CLOSED + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + + @callback + def _process_binary_valve_update( + self, payload: ReceivePayloadType, state_payload: str + ) -> None: + """Process an update for a valve that does not report the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + elif state_payload == self._config[CONF_STATE_OPEN]: + state = STATE_OPEN + elif state_payload == self._config[CONF_STATE_CLOSED]: + state = STATE_CLOSED + if state is None: + _LOGGER.warning( + "Payload is not one of [open, closed, opening, closing], got: %s", + payload, + ) + return + self._update_state(state) + + @callback + def _process_position_valve_update( + self, payload: ReceivePayloadType, position_payload: str, state_payload: str + ) -> None: + """Process an update for a valve that reports the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + if state is None or position_payload != state_payload: + try: + percentage_payload = ranged_value_to_percentage( + self._range, float(position_payload) + ) + except ValueError: + _LOGGER.warning("Payload '%s' is not numeric", position_payload) + return + + self._attr_current_valve_position = min(max(percentage_payload, 0), 100) + if state is None: + _LOGGER.warning( + "Payload is not one of [opening, closing], got: %s", + payload, + ) + return + self._update_state(state) + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics = {} + + @callback + @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) + def state_message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload_dict: Any = None + position_payload: Any = None + state_payload: Any = None + payload = self._value_template(msg.payload) + + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + if isinstance(payload_dict, dict) and "position" in payload_dict: + position_payload = payload_dict["position"] + if isinstance(payload_dict, dict) and "state" in payload_dict: + state_payload = payload_dict["state"] + state_payload = payload if state_payload is None else state_payload + position_payload = payload if position_payload is None else position_payload + + if self._config[CONF_REPORTS_POSITION]: + self._process_position_valve_update( + payload, position_payload, state_payload + ) + else: + self._process_binary_valve_update(payload, state_payload) + + if self._config.get(CONF_STATE_TOPIC): + topics["state_topic"] = { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": state_message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_open_valve(self) -> None: + """Move the valve up. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_OPEN, DEFAULT_PAYLOAD_OPEN) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_OPEN) + self.async_write_ha_state() + + async def async_close_valve(self) -> None: + """Move the valve down. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_CLOSE) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_CLOSED) + self.async_write_ha_state() + + async def async_stop_valve(self) -> None: + """Stop valve positioning. + + This method is a coroutine. + """ + payload = self._command_template(self._config[CONF_PAYLOAD_STOP]) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + percentage_position = position + scaled_position = round( + percentage_to_ranged_value(self._range, percentage_position) + ) + variables = { + "position": percentage_position, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + } + rendered_position = self._command_template(scaled_position, variables=variables) + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + rendered_position, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + self._update_state( + STATE_CLOSED + if percentage_position == self._config[CONF_POSITION_CLOSED] + else STATE_OPEN + ) + self._attr_current_valve_position = percentage_position + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py new file mode 100644 index 00000000000..27be72ecabc --- /dev/null +++ b/tests/components/mqtt/test_valve.py @@ -0,0 +1,1399 @@ +"""The tests for the MQTT valve platform.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, valve +from homeassistant.components.mqtt.valve import ( + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ValveEntityFeature, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + SERVICE_SET_VALVE_POSITION, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_custom_config, + 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_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_skipped_async_ha_write_state, + 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 +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "command_topic": "command-topic", + "state_topic": "test-topic", + "name": "test", + } + } +} + +DEFAULT_CONFIG_REPORTS_POSITION = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "test-topic", + "reports_position": True, + } + } +} + + +@pytest.fixture(autouse=True) +def valve_platform_only(): + """Only setup the valve platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ("open", STATE_OPEN), + ("closed", STATE_CLOSED), + ("closing", STATE_CLOSING), + ("opening", STATE_OPENING), + ('{"state" : "open"}', STATE_OPEN), + ('{"state" : "closed"}', STATE_CLOSED), + ('{"state" : "closing"}', STATE_CLOSING), + ('{"state" : "opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_no_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic without position and without template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "value_template": "{{ value_json.state }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"state":"open"}', STATE_OPEN), + ('{"state":"closed"}', STATE_CLOSED), + ('{"state":"closing"}', STATE_CLOSING), + ('{"state":"opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_with_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "value_template": "{{ value_json.position }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"position":100}', STATE_OPEN), + ('{"position":50.0}', STATE_OPEN), + ('{"position":0}', STATE_CLOSED), + ('{"position":"non_numeric"}', STATE_UNKNOWN), + ('{"ignored":12}', STATE_UNKNOWN), + ], +) +async def test_state_via_state_topic_with_position_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with position template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("0", STATE_CLOSED, 0), + ("opening", STATE_OPENING, None), + ("50", STATE_OPEN, 50), + ("closing", STATE_CLOSING, None), + ("100", STATE_OPEN, 100), + ("open", STATE_UNKNOWN, None), + ("closed", STATE_UNKNOWN, None), + ("-10", STATE_CLOSED, 0), + ("110", STATE_OPEN, 100), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 10, "state": "opening"}', STATE_OPENING, 10), + ('{"position": 50, "state": "open"}', STATE_OPEN, 50), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 0, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -10, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 110, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_topic_through_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("-128", STATE_CLOSED, 0), + ("0", STATE_OPEN, 50), + ("127", STATE_OPEN, 100), + ("-130", STATE_CLOSED, 0), + ("130", STATE_OPEN, 100), + ('{"position": -128, "state": "opening"}', STATE_OPENING, 0), + ('{"position": -30, "state": "opening"}', STATE_OPENING, 38), + ('{"position": 30, "state": "open"}', STATE_OPEN, 61), + ('{"position": 127, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 89), + ('{"position": -128, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -130, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 130, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_trough_position_with_alt_range( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position and an alternative range. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "SToP", + "payload_open": "OPeN", + "payload_close": "CLOsE", + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "CLOsE"), + (SERVICE_OPEN_VALVE, "OPeN"), + (SERVICE_STOP_VALVE, "SToP"), + ], +) +async def tests_controling_valve_by_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by state.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("hass_config", "supported_features"), + [ + (DEFAULT_CONFIG, ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": "CLOSE"},), + ), + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": None},), + ), + ValveEntityFeature.OPEN, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": None, "payload_close": "CLOSE"},), + ), + ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG, ({"payload_stop": "STOP"},) + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG_REPORTS_POSITION, + ({"payload_stop": "STOP"},), + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ], +) +async def tests_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + supported_features: ValveEntityFeature, +) -> None: + """Test the valve's supported features.""" + assert await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state is not None + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == supported_features + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_open": "OPEN"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_close": "CLOSE"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_open": "open"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_closed": "closed"},) + ), + ], +) +async def tests_open_close_payload_config_not_allowed( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test open or close payload configs fail if valve reports position.""" + assert await mqtt_mock_entry() + + assert hass.states.get("valve.test") is None + + assert ( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state"), + [ + (SERVICE_CLOSE_VALVE, "CLOSE", STATE_CLOSED), + (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), + ], +) +async def tests_controling_valve_by_state_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "0"), + (SERVICE_OPEN_VALVE, "100"), + (SERVICE_STOP_VALVE, "-1"), + ], +) +async def tests_controling_valve_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "0"), + (30, "30"), + (100, "100"), + ], +) +async def tests_controling_valve_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "0", 0, STATE_CLOSED), + (30, "30", 30, STATE_OPEN), + (100, "100", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "-128"), + (30, "-52"), + (80, "76"), + (100, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "-128"), + (SERVICE_OPEN_VALVE, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + "reports_position": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + "reports_position": True, + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state", "asserted_position"), + [ + (SERVICE_CLOSE_VALVE, "0", STATE_CLOSED, 0), + (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), + ], +) +async def tests_controling_valve_by_position_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, + asserted_position: int, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes[ATTR_CURRENT_POSITION] == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "-128", 0, STATE_CLOSED), + (30, "-52", 30, STATE_OPEN), + (50, "0", 50, STATE_OPEN), + (100, "127", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_alt_trange_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic and alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@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, valve.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, valve.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, valve.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, valve.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "water", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.attributes.get("device_class") == "water" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "abc123", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + assert await mqtt_mock_entry() + assert "expected ValveDeviceClass" in caplog.text + + +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, valve.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, + valve.DOMAIN, + DEFAULT_CONFIG, + MQTT_VALVE_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, valve.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, + valve.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, + valve.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, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique_id option only creates one valve per id.""" + await help_test_unique_id(hass, mqtt_mock_entry, valve.DOMAIN) + + +async def test_discovery_removal_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered valve.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, valve.DOMAIN, data) + + +async def test_discovery_update_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + config1 = {"name": "Beer", "command_topic": "test_topic"} + config2 = {"name": "Milk", "command_topic": "test_topic"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.valve.MqttValve.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + valve.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 = '{ "name": "Beer", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, valve.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, valve.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, valve.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.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +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, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG, + SERVICE_OPEN_VALVE, + command_payload="OPEN", + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_OPEN_VALVE, + "command_topic", + None, + "OPEN", + None, + ), + ], +) +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 = valve.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 = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "open", None, None), + ("state_topic", "closing", None, None), + ], +) +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.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][valve.DOMAIN], + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) + + +@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 = valve.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 = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 33bcf70bf3ee43a16eae91a03cea3a3350cb1775 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Wed, 20 Dec 2023 11:51:46 +0300 Subject: [PATCH 539/927] Add Starline Service Mode switch (#105741) * Starline Service Mode switch * Update homeassistant/components/starline/strings.json Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/starline/account.py | 1 + homeassistant/components/starline/strings.json | 3 +++ homeassistant/components/starline/switch.py | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index f0dea666085..d2b7e3a4aa1 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -136,6 +136,7 @@ class StarlineAccount: model=device.typename, name=device.name, sw_version=device.fw_version, + configuration_url="https://starline-online.ru/", ) @staticmethod diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 55aa8532081..99cae9650ff 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -104,6 +104,9 @@ }, "horn": { "name": "Horn" + }, + "service_mode": { + "name": "Service mode" } }, "button": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 600dac34fe3..ef24dd52c02 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -56,6 +56,12 @@ SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( icon_on="mdi:bullhorn-outline", icon_off="mdi:bullhorn-outline", ), + StarlineSwitchEntityDescription( + key="valet", + translation_key="service_mode", + icon_on="mdi:wrench-clock", + icon_off="mdi:car-wrench", + ), ) From 3e07cf50ce82fe1559c0f2e1183c1906696953c7 Mon Sep 17 00:00:00 2001 From: On Freund Date: Wed, 20 Dec 2023 11:35:42 +0200 Subject: [PATCH 540/927] Don't fetch unchanged OurGroceries lists (#105998) --- .../components/ourgroceries/__init__.py | 4 +-- .../components/ourgroceries/coordinator.py | 25 ++++++++------- tests/components/ourgroceries/__init__.py | 4 +-- tests/components/ourgroceries/conftest.py | 2 +- tests/components/ourgroceries/test_todo.py | 32 ++++++++++++++++++- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index d645b8617c2..ebb928e72d0 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -24,16 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) data = entry.data og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) - lists = [] try: await og.login() - lists = (await og.get_my_lists())["shoppingLists"] except (AsyncIOTimeoutError, ClientError) as error: raise ConfigEntryNotReady from error except InvalidLoginException: return False - coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + coordinator = OurGroceriesDataUpdateCoordinator(hass, og) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index 636ebcc300a..c583fb4d5b1 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -20,13 +20,11 @@ _LOGGER = logging.getLogger(__name__) class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - def __init__( - self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] - ) -> None: + def __init__(self, hass: HomeAssistant, og: OurGroceries) -> None: """Initialize global OurGroceries data updater.""" self.og = og - self.lists = lists - self._ids = [sl["id"] for sl in lists] + self.lists: list[dict] = [] + self._cache: dict[str, dict] = {} interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, @@ -35,13 +33,16 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_interval=interval, ) + async def _update_list(self, list_id: str, version_id: str) -> None: + old_version = self._cache.get(list_id, {}).get("list", {}).get("versionId", "") + if old_version == version_id: + return + self._cache[list_id] = await self.og.get_list_items(list_id=list_id) + async def _async_update_data(self) -> dict[str, dict]: """Fetch data from OurGroceries.""" - return dict( - zip( - self._ids, - await asyncio.gather( - *[self.og.get_list_items(list_id=id) for id in self._ids] - ), - ) + self.lists = (await self.og.get_my_lists())["shoppingLists"] + await asyncio.gather( + *[self._update_list(sl["id"], sl["versionId"]) for sl in self.lists] ) + return self._cache diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py index 67fcb439908..6f90cb7ea1b 100644 --- a/tests/components/ourgroceries/__init__.py +++ b/tests/components/ourgroceries/__init__.py @@ -1,6 +1,6 @@ """Tests for the OurGroceries integration.""" -def items_to_shopping_list(items: list) -> dict[dict[list]]: +def items_to_shopping_list(items: list, version_id: str = "1") -> dict[dict[list]]: """Convert a list of items into a shopping list.""" - return {"list": {"items": items}} + return {"list": {"versionId": version_id, "items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py index 7f113da2633..c5fdec3ecb7 100644 --- a/tests/components/ourgroceries/conftest.py +++ b/tests/components/ourgroceries/conftest.py @@ -46,7 +46,7 @@ def mock_ourgroceries(items: list[dict]) -> AsyncMock: og = AsyncMock() og.login.return_value = True og.get_my_lists.return_value = { - "shoppingLists": [{"id": "test_list", "name": "Test List"}] + "shoppingLists": [{"id": "test_list", "name": "Test List", "versionId": "1"}] } og.get_list_items.return_value = items_to_shopping_list(items) return og diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 8686c52d79b..649e86f2b05 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -17,6 +17,10 @@ from . import items_to_shopping_list from tests.common import async_fire_time_changed +def _mock_version_id(og: AsyncMock, version: int) -> None: + og.get_my_lists.return_value["shoppingLists"][0]["versionId"] = str(version) + + @pytest.mark.parametrize( ("items", "expected_state"), [ @@ -57,8 +61,10 @@ async def test_add_todo_list_item( ourgroceries.add_item_to_list = AsyncMock() # Fake API response when state is refreshed after create + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( - [{"id": "12345", "name": "Soda"}] + [{"id": "12345", "name": "Soda"}], + version_id="2", ) await hass.services.async_call( @@ -95,6 +101,7 @@ async def test_update_todo_item_status( ourgroceries.toggle_item_crossed_off = AsyncMock() # Fake API response when state is refreshed after crossing off + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] ) @@ -118,6 +125,7 @@ async def test_update_todo_item_status( assert state.state == "0" # Fake API response when state is refreshed after reopen + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda"}] ) @@ -166,6 +174,7 @@ async def test_update_todo_item_summary( ourgroceries.change_item_on_list = AsyncMock() # Fake API response when state is refreshed update + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Milk"}] ) @@ -204,6 +213,7 @@ async def test_remove_todo_item( ourgroceries.remove_item_from_list = AsyncMock() # Fake API response when state is refreshed after remove + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list([]) await hass.services.async_call( @@ -224,6 +234,25 @@ async def test_remove_todo_item( assert state.state == "0" +async def test_version_id_optimization( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test that list items aren't being retrieved if version id stays the same.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + + @pytest.mark.parametrize( ("exception"), [ @@ -242,6 +271,7 @@ async def test_coordinator_error( state = hass.states.get("todo.test_list") assert state.state == "0" + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.side_effect = exception freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) From 56967a92e0e588615e2fcfa5dbcd641d5a471f16 Mon Sep 17 00:00:00 2001 From: Ben <512997+benleb@users.noreply.github.com> Date: Wed, 20 Dec 2023 15:46:54 +0100 Subject: [PATCH 541/927] Bump surepy to 0.9.0 (#106101) --- homeassistant/components/surepetcare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 89e018b6635..bcfd10d2f02 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/surepetcare", "iot_class": "cloud_polling", "loggers": ["rich", "surepy"], - "requirements": ["surepy==0.8.0"] + "requirements": ["surepy==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 021a8d8b131..80108233bdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2561,7 +2561,7 @@ sunwatcher==0.2.1 sunweg==2.0.1 # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d91b576eb31..b69b547588d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1925,7 +1925,7 @@ sunwatcher==0.2.1 sunweg==2.0.1 # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.switchbot_cloud switchbot-api==1.3.0 From 5a3db078d5880666141f2b8eecb2c8d1a2c0c641 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 16:06:26 +0100 Subject: [PATCH 542/927] Use patch.dict in deprecation test "test_check_if_deprecated_constant" (#106117) --- tests/helpers/test_deprecation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4ad1677a16f..6816c7701aa 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -293,10 +293,8 @@ def test_check_if_deprecated_constant( } filename = f"/home/paulus/{module_name.replace('.', '/')}.py" - # mock module for homeassistant/helpers/frame.py#get_integration_frame - sys.modules[module_name] = Mock(__file__=filename) - - with patch( + # mock sys.modules for homeassistant/helpers/frame.py#get_integration_frame + with patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), patch( "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( From 93c800c4e853f3ac500ab0a805bdab8048257dc3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 16:48:02 +0100 Subject: [PATCH 543/927] Add water_heater to alexa (#106011) * Add water_heater support to alexa * Improve test coverage * Follow up comments --- .../components/alexa/capabilities.py | 47 ++++- homeassistant/components/alexa/entities.py | 35 +++- homeassistant/components/alexa/handlers.py | 56 +++++- tests/components/alexa/test_capabilities.py | 121 ++++++++++++ tests/components/alexa/test_common.py | 4 +- tests/components/alexa/test_smart_home.py | 175 ++++++++++++++++++ 6 files changed, 424 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 0856c39946b..955502c8149 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ from homeassistant.components import ( number, timer, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, @@ -435,7 +436,8 @@ class AlexaPowerController(AlexaCapability): is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE - + elif self.entity.domain == water_heater.DOMAIN: + is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) else: is_on = self.entity.state != STATE_OFF @@ -938,6 +940,9 @@ class AlexaTemperatureSensor(AlexaCapability): if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + elif self.entity.domain == water_heater.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE) if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None @@ -1108,6 +1113,8 @@ class AlexaThermostatController(AlexaCapability): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: properties.append({"name": "targetSetpoint"}) + if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: properties.append({"name": "lowerSetpoint"}) properties.append({"name": "upperSetpoint"}) @@ -1127,6 +1134,8 @@ class AlexaThermostatController(AlexaCapability): return None if name == "thermostatMode": + if self.entity.domain == water_heater.DOMAIN: + return None preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) mode: dict[str, str] | str | None @@ -1176,9 +1185,13 @@ class AlexaThermostatController(AlexaCapability): ThermostatMode Values. ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + Water heater devices do not return thermostat modes. """ + if self.entity.domain == water_heater.DOMAIN: + return None + supported_modes: list[str] = [] - hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1408,6 +1421,16 @@ class AlexaModeController(AlexaCapability): if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): return f"{humidifier.ATTR_MODE}.{mode}" + # Water heater operation mode + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = self.entity.attributes.get( + water_heater.ATTR_OPERATION_MODE, None + ) + if operation_mode in self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ): + return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1478,6 +1501,26 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Water heater operation modes + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + operation_modes = self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ) + for operation_mode in operation_modes: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}", + [operation_mode], + ) + # Devices with a single mode completely break Alexa discovery, + # add a fake preset (see issue #53832). + if len(operation_modes) == 1: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{PRESET_MODE_NA}", + [PRESET_MODE_NA], + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index da0bd8b36aa..2f89058514b 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,7 @@ from homeassistant.components import ( switch, timer, vacuum, + water_heater, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -248,6 +249,9 @@ class DisplayCategory: # Indicates a vacuum cleaner. VACUUM_CLEANER = "VACUUM_CLEANER" + # Indicates a water heater. + WATER_HEATER = "WATER_HEATER" + # Indicates a network-connected wearable device, such as an Apple Watch, # Fitbit, or Samsung Gear. WEARABLE = "WEARABLE" @@ -456,23 +460,46 @@ class ButtonCapabilities(AlexaEntity): @ENTITY_ADAPTERS.register(climate.DOMAIN) +@ENTITY_ADAPTERS.register(water_heater.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" + if self.entity.domain == water_heater.DOMAIN: + return [DisplayCategory.WATER_HEATER] return [DisplayCategory.THERMOSTAT] def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. - if climate.HVACMode.OFF in self.entity.attributes.get( - climate.ATTR_HVAC_MODES, [] + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + or self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): yield AlexaPowerController(self.entity) - yield AlexaThermostatController(self.hass, self.entity) - yield AlexaTemperatureSensor(self.hass, self.entity) + if ( + self.entity.domain == climate.DOMAIN + or self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + ): + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + if self.entity.domain == water_heater.DOMAIN and ( + supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ): + yield AlexaModeController( + self.entity, + instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}", + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 2796c10795b..8e81cf1a2c6 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,7 @@ from homeassistant.components import ( number, timer, vacuum, + water_heater, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -80,6 +81,23 @@ from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_ _LOGGER = logging.getLogger(__name__) DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" + +MIN_MAX_TEMP = { + climate.DOMAIN: { + "min_temp": climate.ATTR_MIN_TEMP, + "max_temp": climate.ATTR_MAX_TEMP, + }, + water_heater.DOMAIN: { + "min_temp": water_heater.ATTR_MIN_TEMP, + "max_temp": water_heater.ATTR_MAX_TEMP, + }, +} + +SERVICE_SET_TEMPERATURE = { + climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE, + water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE, +} + HANDLERS: Registry[ tuple[str, str], Callable[ @@ -804,8 +822,10 @@ async def async_api_set_target_temp( ) -> AlexaResponse: """Process a set target temperature request.""" entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes["max_temp"] unit = hass.config.units.temperature_unit data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} @@ -849,9 +869,11 @@ async def async_api_set_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -867,11 +889,12 @@ async def async_api_adjust_target_temp( directive: AlexaDirective, context: ha.Context, ) -> AlexaResponse: - """Process an adjust target temperature request.""" + """Process an adjust target temperature request for climates and water heaters.""" data: dict[str, Any] entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]] unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( @@ -932,9 +955,11 @@ async def async_api_adjust_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -1163,6 +1188,23 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" raise AlexaInvalidValueError(msg) + # Water heater operation mode + elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = mode.split(".")[1] + operation_modes: list[str] | None = entity.attributes.get( + water_heater.ATTR_OPERATION_LIST + ) + if ( + operation_mode != PRESET_MODE_NA + and operation_modes + and operation_mode in operation_modes + ): + service = water_heater.SERVICE_SET_OPERATION_MODE + data[water_heater.ATTR_OPERATION_MODE] = operation_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 11e39c40cb1..7c39e34ac38 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,13 @@ from homeassistant.components.alexa import smart_home from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_GAS, + STATE_HEAT_PUMP, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ALARM_ARMED_AWAY, @@ -16,6 +23,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_LOCKED, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -777,6 +785,96 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_water_heater_state(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP): + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO, STATE_GAS, STATE_HEAT_PUMP], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_mode in [STATE_OFF]: + hass.states.async_set( + "water_heater.boyler", + off_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for state in "unavailable", "unknown": + hass.states.async_set( + f"water_heater.{state}", + state, + {"friendly_name": f"Boyler {state}", "supported_features": 11}, + ) + properties = await reported_properties(hass, f"water_heater.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + + +async def test_report_singe_mode_water_heater(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + operation_mode = STATE_ECO + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + async def test_temperature_sensor_sensor(hass: HomeAssistant) -> None: """Test TemperatureSensor reports sensor temperature correctly.""" for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): @@ -823,6 +921,29 @@ async def test_temperature_sensor_climate(hass: HomeAssistant) -> None: ) +async def test_temperature_sensor_water_heater(hass: HomeAssistant) -> None: + """Test TemperatureSensor reports climate temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: bad_value}, + ) + + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: 34}, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) + + async def test_report_alarm_control_panel_state(hass: HomeAssistant) -> None: """Test SecurityPanelController implements armState property.""" hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {}) diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index 4cbe112af49..d3ea1bcda3e 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -128,12 +128,14 @@ async def assert_request_calls_service( async def assert_request_fails( - namespace, name, endpoint, service_not_called, hass, payload=None + namespace, name, endpoint, service_not_called, hass, payload=None, instance=None ): """Assert an API request returns an ErrorResponse.""" request = get_new_request(namespace, name, endpoint) if payload: request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance domain, service_name = service_not_called.split(".") call = async_mock_service(hass, domain, service_name) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0a5b1f79f72..d025b1586f5 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2700,6 +2700,181 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_water_heater(hass: HomeAssistant) -> None: + """Test water_heater discovery.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "water_heater.boyler", + "gas", + { + "temperature": 70.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test water heater", + "supported_features": 1 | 2 | 8, + "operation_list": ["off", "gas", "eco"], + "operation_mode": "gas", + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "water_heater#boyler" + assert appliance["displayCategories"][0] == "WATER_HEATER" + assert appliance["friendlyName"] == "Test water heater" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.ModeController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "water_heater#boyler") + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 70.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + modes_capability = get_capability(capabilities, "Alexa.ModeController") + assert modes_capability is not None + configuration = modes_capability["configuration"] + + supported_modes = ["operation_mode.off", "operation_mode.gas", "operation_mode.eco"] + for mode in supported_modes: + assert mode in [item["value"] for item in configuration["supportedModes"]] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 69.0, "scale": "FAHRENHEIT"}}, + ) + assert call.data["temperature"] == 69.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 69.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={ + "targetSetpoint": {"value": 30.0, "scale": "CELSIUS"}, + }, + ) + assert call.data["temperature"] == 86.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 86.0, "scale": "FAHRENHEIT"}, + ) + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -10.0, "scale": "KELVIN"}}, + ) + assert call.data["temperature"] == 52.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 52.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": 20.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + # Setting mode, the payload can be an object with a value attribute... + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.eco"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "eco" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.eco") + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.gas"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "gas" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + + # assert unsupported mode + msg = await assert_request_fails( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.invalid"}, + instance="water_heater.operation_mode", + ) + assert msg["event"]["payload"]["type"] == "INVALID_VALUE" + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.off"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "off" + + async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: """Test thermostat adjusting temp with no initial target temperature.""" hass.config.units = US_CUSTOMARY_SYSTEM From 58759ff6b7d748e59e9bc976ca37ab385e5f85e2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 17:20:50 +0100 Subject: [PATCH 544/927] Deprecate deprecated remote constants (#106116) --- homeassistant/components/remote/__init__.py | 22 ++++++++++++++++++--- tests/components/remote/test_init.py | 13 +++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 2901c14c455..a85784a33a7 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -25,6 +25,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -70,9 +75,20 @@ class RemoteEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the RemoteEntityFeature enum instead. -SUPPORT_LEARN_COMMAND = 1 -SUPPORT_DELETE_COMMAND = 2 -SUPPORT_ACTIVITY = 4 +_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.LEARN_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.DELETE_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( + RemoteEntityFeature.ACTIVITY, "2025.1" +) + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) REMOTE_SERVICE_ACTIVITY_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 6219943693b..b185b229cd2 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,4 +1,6 @@ """The tests for the Remote component, adapted from Light Test.""" +import pytest + import homeassistant.components.remote as remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -20,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -139,3 +141,12 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID + + +@pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: remote.RemoteEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") From 036c856500edda6af42d74413d83fb8bdd81e803 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 17:28:52 +0100 Subject: [PATCH 545/927] Deprecate deprecated number constants (#106114) --- homeassistant/components/number/const.py | 33 ++++++++++++++++-------- tests/components/number/test_const.py | 16 ++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 tests/components/number/test_const.py diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9248d3f9e57..4107509e01f 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter ATTR_VALUE = "value" @@ -50,10 +56,23 @@ DOMAIN = "number" SERVICE_SET_VALUE = "set_value" + +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + # MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. -MODE_AUTO: Final = "auto" -MODE_BOX: Final = "box" -MODE_SLIDER: Final = "slider" +_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") +_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") +_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class NumberDeviceClass(StrEnum): @@ -385,14 +404,6 @@ class NumberDeviceClass(StrEnum): """ -class NumberMode(StrEnum): - """Modes for number entities.""" - - AUTO = "auto" - BOX = "box" - SLIDER = "slider" - - DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py new file mode 100644 index 00000000000..e4b47e17e6e --- /dev/null +++ b/tests/components/number/test_const.py @@ -0,0 +1,16 @@ +"""Test the number const module.""" + +import pytest + +from homeassistant.components.number import const + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize(("enum"), list(const.NumberMode)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: const.NumberMode, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, const, enum, "MODE_", "2025.1") From 2403b21c4ff74f224e4b6f435fa098313e515822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 06:37:52 -1000 Subject: [PATCH 546/927] Bump zeroconf to 0.131.0 (#106037) --- 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 8e5514696d2..aecc88968f3 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.130.0"] + "requirements": ["zeroconf==0.131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b6e8ba3db4..50b6fe01a6b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.130.0 +zeroconf==0.131.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 80108233bdf..681dffea86f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2837,7 +2837,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.130.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b69b547588d..874b2d3200d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2135,7 +2135,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.130.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From c9c072ff3e5ca98eba24a8fe53632ad56bcf4406 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 17:54:43 +0100 Subject: [PATCH 547/927] Deprecate deprecated fan constants (#106111) --- homeassistant/components/fan/__init__.py | 25 ++++++++++++++++++++---- tests/components/fan/test_init.py | 11 +++++++++++ tests/components/tasmota/test_fan.py | 2 +- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 23261c4d944..ec6fc1aad7e 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -24,6 +24,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -52,10 +57,22 @@ class FanEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the FanEntityFeature enum instead. -SUPPORT_SET_SPEED = 1 -SUPPORT_OSCILLATE = 2 -SUPPORT_DIRECTION = 4 -SUPPORT_PRESET_MODE = 8 +_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum( + FanEntityFeature.SET_SPEED, "2025.1" +) +_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum( + FanEntityFeature.OSCILLATE, "2025.1" +) +_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum( + FanEntityFeature.DIRECTION, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + FanEntityFeature.PRESET_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index ec421141768..e6a3ab546cc 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,6 +1,7 @@ """Tests for fan platforms.""" import pytest +from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, @@ -13,6 +14,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum from tests.testing_config.custom_components.test.fan import MockFan @@ -145,3 +147,12 @@ async def test_preset_mode_validation( with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") assert exc.value.translation_key == "not_valid_preset_mode" + + +@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: fan.FanEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 05e3151be2e..727fddc9bd3 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -60,7 +60,7 @@ async def test_controlling_state_via_mqtt( state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["percentage"] is None - assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED + assert state.attributes["supported_features"] == fan.FanEntityFeature.SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') From 9830f77e9e0dbdba6a4ef34666a8d398b02ba9c7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 18:04:44 +0100 Subject: [PATCH 548/927] Deprecate deprecated cover constants (#106098) --- homeassistant/components/cover/__init__.py | 71 ++++++++++++++----- .../components/cover/device_action.py | 27 +++---- .../components/cover/device_condition.py | 16 ++--- .../components/cover/device_trigger.py | 16 ++--- tests/components/cover/test_init.py | 29 ++++++++ tests/components/tasmota/test_cover.py | 16 ++--- .../custom_components/test/cover.py | 68 ++++++++---------- 7 files changed, 139 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 633300af591..1f365b07099 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -32,6 +32,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -69,16 +74,32 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the CoverDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] -DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value -DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value -DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value -DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value -DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value -DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value -DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value -DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value -DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum( + CoverDeviceClass.AWNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum( + CoverDeviceClass.BLIND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum( + CoverDeviceClass.CURTAIN, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum( + CoverDeviceClass.DAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1") +_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum( + CoverDeviceClass.GARAGE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1") +_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum( + CoverDeviceClass.SHADE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum( + CoverDeviceClass.SHUTTER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + CoverDeviceClass.WINDOW, "2025.1" +) # mypy: disallow-any-generics @@ -98,14 +119,28 @@ class CoverEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the CoverEntityFeature enum instead. -SUPPORT_OPEN = 1 -SUPPORT_CLOSE = 2 -SUPPORT_SET_POSITION = 4 -SUPPORT_STOP = 8 -SUPPORT_OPEN_TILT = 16 -SUPPORT_CLOSE_TILT = 32 -SUPPORT_STOP_TILT = 64 -SUPPORT_SET_TILT_POSITION = 128 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1") +_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1") +_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_POSITION, "2025.1" +) +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1") +_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum( + CoverEntityFeature.OPEN_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum( + CoverEntityFeature.CLOSE_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum( + CoverEntityFeature.STOP_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_TILT_POSITION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index e34a623be93..2224e5bab1c 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -24,18 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, -) +from . import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} @@ -88,20 +77,20 @@ async def async_get_actions( CONF_ENTITY_ID: entry.id, } - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - if supported_features & SUPPORT_OPEN: + if supported_features & CoverEntityFeature.OPEN: actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: + if supported_features & CoverEntityFeature.CLOSE: actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: + if supported_features & CoverEntityFeature.STOP: actions.append({**base_action, CONF_TYPE: "stop"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - if supported_features & SUPPORT_OPEN_TILT: + if supported_features & CoverEntityFeature.OPEN_TILT: actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: + if supported_features & CoverEntityFeature.CLOSE_TILT: actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 2aa0a1dd2fb..23ec7d75650 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -26,13 +26,7 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature # mypy: disallow-any-generics @@ -78,7 +72,9 @@ async def async_get_conditions( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add conditions for each entity that belongs to this integration base_condition = { @@ -92,9 +88,9 @@ async def async_get_conditions( conditions += [ {**base_condition, CONF_TYPE: cond} for cond in STATE_CONDITION_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_position"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_tilt_position"}) return conditions diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 2fb456d726d..8225348619d 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -29,13 +29,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} @@ -80,7 +74,9 @@ async def async_get_triggers( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add triggers for each entity that belongs to this integration base_trigger = { @@ -98,14 +94,14 @@ async def async_get_triggers( } for trigger in STATE_TRIGGER_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: triggers.append( { **base_trigger, CONF_TYPE: "position", } ) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: triggers.append( { **base_trigger, diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 802bf759d81..062440e6b39 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,4 +1,8 @@ """The tests for Cover.""" +from enum import Enum + +import pytest + import homeassistant.components.cover as cover from homeassistant.const import ( ATTR_ENTITY_ID, @@ -12,6 +16,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum + async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test the provided services.""" @@ -112,3 +118,26 @@ def is_closed(hass, ent): def is_closing(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_CLOSING) + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(cover.CoverEntityFeature, "SUPPORT_") + + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, cover, enum, constant_prefix, "2025.1" + ) diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index cae65521e21..26f8dee4a9d 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -35,16 +35,16 @@ from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient COVER_SUPPORT = ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP + | cover.CoverEntityFeature.SET_POSITION ) TILT_SUPPORT = ( - cover.SUPPORT_OPEN_TILT - | cover.SUPPORT_CLOSE_TILT - | cover.SUPPORT_STOP_TILT - | cover.SUPPORT_SET_TILT_POSITION + cover.CoverEntityFeature.OPEN_TILT + | cover.CoverEntityFeature.CLOSE_TILT + | cover.CoverEntityFeature.STOP_TILT + | cover.CoverEntityFeature.SET_TILT_POSITION ) diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 51a4a9dc83b..2a57412ea9e 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,17 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import ( - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, - SUPPORT_STOP_TILT, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from tests.common import MockEntity @@ -32,38 +22,38 @@ def init(empty=False): name="Simple cover", is_on=True, unique_id="unique_cover", - supported_features=SUPPORT_OPEN | SUPPORT_CLOSE, + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, ), MockCover( name="Set position cover", is_on=True, unique_id="unique_set_pos_cover", current_cover_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION, ), MockCover( name="Simple tilt cover", is_on=True, unique_id="unique_tilt_cover", - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT, ), MockCover( name="Set tilt position cover", is_on=True, unique_id="unique_set_pos_tilt_cover", current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), MockCover( name="All functions cover", @@ -71,14 +61,14 @@ def init(empty=False): unique_id="unique_all_functions_cover", current_cover_position=50, current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), ] ) @@ -97,7 +87,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: return self.current_cover_position == 0 if "state" in self._values: @@ -107,7 +97,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_OPENING @@ -116,7 +106,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_CLOSING @@ -124,14 +114,14 @@ class MockCover(MockEntity, CoverEntity): def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED From 28e4358c53a4363ffecb855abbfc9d1cfe64f923 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 18:05:43 +0100 Subject: [PATCH 549/927] Deprecate deprecated humidifier constants (#106112) --- .../components/humidifier/__init__.py | 17 ++++++++-- homeassistant/components/humidifier/const.py | 24 ++++++++++++-- tests/components/humidifier/test_init.py | 32 +++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 39150126b7a..d9c804279b2 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Any, final @@ -22,12 +23,19 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, + _DEPRECATED_DEVICE_CLASS_HUMIDIFIER, + _DEPRECATED_SUPPORT_MODES, ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, @@ -36,15 +44,12 @@ from .const import ( # noqa: F401 ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, DOMAIN, MODE_AUTO, MODE_AWAY, MODE_NORMAL, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, - SUPPORT_MODES, HumidifierAction, HumidifierEntityFeature, ) @@ -70,6 +75,12 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(HumidifierDeviceClass)) # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 09c0714cbeb..a1a219ddce7 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -35,8 +43,12 @@ DOMAIN = "humidifier" # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the HumidifierDeviceClass enum instead. -DEVICE_CLASS_HUMIDIFIER = "humidifier" -DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" +_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant( + "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant( + "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1" +) SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -50,4 +62,10 @@ class HumidifierEntityFeature(IntFlag): # The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5. # Please use the HumidifierEntityFeature enum instead. -SUPPORT_MODES = 1 +_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( + HumidifierEntityFeature.MODES, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index a80f3956f20..da45e1f1661 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -1,9 +1,16 @@ """The tests for the humidifier component.""" +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock +import pytest + +from homeassistant.components import humidifier from homeassistant.components.humidifier import HumidifierEntity from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockHumidifierEntity(HumidifierEntity): """Mock Humidifier device to use in tests.""" @@ -34,3 +41,28 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await humidifier.async_turn_off() assert humidifier.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") + + _create_tuples(humidifier.HumidifierDeviceClass, "DEVICE_CLASS_"), +) +@pytest.mark.parametrize(("module"), [humidifier, humidifier.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) From 9dd1b9e268d87eb7863ffbbd96777c9f1796a3ac Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 18:06:38 +0100 Subject: [PATCH 550/927] Deprecate deprecated device tracker constants (#106099) --- .../components/device_tracker/__init__.py | 20 +++++++++--- .../components/device_tracker/const.py | 31 ++++++++++++++----- tests/components/device_tracker/test_init.py | 18 +++++++++++ .../custom_components/test/device_tracker.py | 4 +-- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a6a8e9d2d8c..b5ad4660cde 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,8 +1,14 @@ """Provide functionality to keep track of devices.""" from __future__ import annotations +from functools import partial + from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -13,6 +19,10 @@ from .config_entry import ( # noqa: F401 async_unload_entry, ) from .const import ( # noqa: F401 + _DEPRECATED_SOURCE_TYPE_BLUETOOTH, + _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE, + _DEPRECATED_SOURCE_TYPE_GPS, + _DEPRECATED_SOURCE_TYPE_ROUTER, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_DEV_ID, @@ -32,10 +42,6 @@ from .const import ( # noqa: F401 DOMAIN, ENTITY_ID_FORMAT, SCAN_INTERVAL, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, SourceType, ) from .legacy import ( # noqa: F401 @@ -51,6 +57,12 @@ from .legacy import ( # noqa: F401 see, ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 3a0b0afd7c9..10c16e09107 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -3,9 +3,16 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" @@ -14,13 +21,6 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY: Final = "legacy" PLATFORM_TYPE_ENTITY: Final = "entity_platform" -# SOURCE_TYPE_* below are deprecated as of 2022.9 -# use the SourceType enum instead. -SOURCE_TYPE_GPS: Final = "gps" -SOURCE_TYPE_ROUTER: Final = "router" -SOURCE_TYPE_BLUETOOTH: Final = "bluetooth" -SOURCE_TYPE_BLUETOOTH_LE: Final = "bluetooth_le" - class SourceType(StrEnum): """Source type for device trackers.""" @@ -31,6 +31,23 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +# SOURCE_TYPE_* below are deprecated as of 2022.9 +# use the SourceType enum instead. +_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1") +_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum( + SourceType.ROUTER, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH_LE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 2960789c646..024187a33f6 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import json import logging import os +from types import ModuleType from unittest.mock import Mock, call, patch import pytest @@ -33,6 +34,7 @@ from . import common from tests.common import ( assert_setup_component, async_fire_time_changed, + import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, patch_yaml_files, @@ -681,3 +683,19 @@ def test_see_schema_allowing_ios_calls() -> None: "hostname": "beer", } ) + + +@pytest.mark.parametrize(("enum"), list(SourceType)) +@pytest.mark.parametrize( + "module", + [device_tracker, device_tracker.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SourceType, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "SOURCE_TYPE_", "2025.1" + ) diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index 31294a48e3d..11eb366f2fc 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import DeviceScanner from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.const import SourceType async def async_get_scanner(hass, config): @@ -23,7 +23,7 @@ class MockScannerEntity(ScannerEntity): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_ROUTER + return SourceType.ROUTER @property def battery_level(self): From 491a50a2f1091dc6f960a7bbb127368a34cdbef4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 18:07:17 +0100 Subject: [PATCH 551/927] Deprecate deprecated lock constants (#106113) --- homeassistant/components/lock/__init__.py | 11 ++++++++++- tests/components/lock/test_init.py | 12 ++++++++++++ tests/testing_config/custom_components/test/lock.py | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index b28aa9d0a1b..a9f31a3a410 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -30,6 +30,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType @@ -57,7 +62,11 @@ class LockEntityFeature(IntFlag): # The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5. # Please use the LockEntityFeature enum instead. -SUPPORT_OPEN = 1 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index d8589ea265e..a03d975ed8a 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -5,6 +5,7 @@ from typing import Any import pytest +from homeassistant.components import lock from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, @@ -25,6 +26,8 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock +from tests.common import import_and_test_deprecated_constant_enum + async def help_test_async_lock_service( hass: HomeAssistant, @@ -353,3 +356,12 @@ async def test_lock_with_illegal_default_code( await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK ) + + +@pytest.mark.parametrize(("enum"), list(LockEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: LockEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index b48e8b1fad9..9cefa34363e 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from tests.common import MockEntity @@ -20,7 +20,7 @@ def init(empty=False): "support_open": MockLock( name="Support open Lock", is_locked=True, - supported_features=SUPPORT_OPEN, + supported_features=LockEntityFeature.OPEN, unique_id="unique_support_open", ), "no_support_open": MockLock( From ea28b74fe91a7fb2ec6636d9d0843b2c5e4f4363 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 18:41:17 +0100 Subject: [PATCH 552/927] Deprecate deprecated alarm control panel constants (#106058) --- .../alarm_control_panel/__init__.py | 27 +++++++---- .../components/alarm_control_panel/const.py | 41 ++++++++++++---- .../alarm_control_panel/device_action.py | 18 +++---- .../alarm_control_panel/device_condition.py | 16 +++---- .../alarm_control_panel/device_trigger.py | 15 ++---- .../alarm_control_panel/test_init.py | 47 +++++++++++++++++++ tests/components/automation/test_init.py | 2 +- .../test/alarm_control_panel.py | 18 +++---- 8 files changed, 123 insertions(+), 61 deletions(-) create mode 100644 tests/components/alarm_control_panel/test_init.py diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index c307e96e9f0..356a8a3164e 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging from typing import Any, Final, final @@ -22,26 +23,36 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_FORMAT_NUMBER, + _DEPRECATED_FORMAT_TEXT, + _DEPRECATED_SUPPORT_ALARM_ARM_AWAY, + _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + _DEPRECATED_SUPPORT_ALARM_ARM_HOME, + _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT, + _DEPRECATED_SUPPORT_ALARM_ARM_VACATION, + _DEPRECATED_SUPPORT_ALARM_TRIGGER, ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, DOMAIN, - FORMAT_NUMBER, - FORMAT_TEXT, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntityFeature, CodeFormat, ) +# As we import constants of the cost module here, we need to add the following +# functions to check for deprecated constants again +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index f14a1ce66e0..90bbcba1314 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,7 +1,14 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" @@ -15,10 +22,10 @@ class CodeFormat(StrEnum): NUMBER = "number" -# These constants are deprecated as of Home Assistant 2022.5 +# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1 # Please use the CodeFormat enum instead. -FORMAT_TEXT: Final = "text" -FORMAT_NUMBER: Final = "number" +_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1") +_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1") class AlarmControlPanelEntityFeature(IntFlag): @@ -34,12 +41,28 @@ class AlarmControlPanelEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the AlarmControlPanelEntityFeature enum instead. -SUPPORT_ALARM_ARM_HOME: Final = 1 -SUPPORT_ALARM_ARM_AWAY: Final = 2 -SUPPORT_ALARM_ARM_NIGHT: Final = 4 -SUPPORT_ALARM_TRIGGER: Final = 8 -SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 -SUPPORT_ALARM_ARM_VACATION: Final = 32 +_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_HOME, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.TRIGGER, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index e453be88934..9c068bb3327 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -28,13 +28,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ATTR_CODE_ARM_REQUIRED, DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, -) +from .const import AlarmControlPanelEntityFeature ACTION_TYPES: Final[set[str]] = { "arm_away", @@ -82,16 +76,16 @@ async def async_get_actions( } # Add actions for each entity that belongs to this integration - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: actions.append({**base_action, CONF_TYPE: "arm_away"}) - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: actions.append({**base_action, CONF_TYPE: "arm_home"}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: actions.append({**base_action, CONF_TYPE: "arm_night"}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: actions.append({**base_action, CONF_TYPE: "arm_vacation"}) actions.append({**base_action, CONF_TYPE: "disarm"}) - if supported_features & SUPPORT_ALARM_TRIGGER: + if supported_features & AlarmControlPanelEntityFeature.TRIGGER: actions.append({**base_action, CONF_TYPE: "trigger"}) return actions diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index ee8cb57f568..e3c627d17a3 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -39,11 +39,7 @@ from .const import ( CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, + AlarmControlPanelEntityFeature, ) CONDITION_TYPES: Final[set[str]] = { @@ -90,15 +86,15 @@ async def async_get_conditions( {**base_condition, CONF_TYPE: CONDITION_DISARMED}, {**base_condition, CONF_TYPE: CONDITION_TRIGGERED}, ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_HOME}) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) - if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: + if supported_features & AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: conditions.append( {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} ) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index fc3850dce30..e5141a1dfd5 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -29,12 +29,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, -) +from .const import AlarmControlPanelEntityFeature BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { @@ -82,28 +77,28 @@ async def async_get_triggers( } for trigger in BASIC_TRIGGER_TYPES ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: triggers.append( { **base_trigger, CONF_TYPE: "armed_home", } ) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: triggers.append( { **base_trigger, CONF_TYPE: "armed_away", } ) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: triggers.append( { **base_trigger, CONF_TYPE: "armed_night", } ) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: triggers.append( { **base_trigger, diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py new file mode 100644 index 00000000000..c447119c119 --- /dev/null +++ b/tests/components/alarm_control_panel/test_init.py @@ -0,0 +1,47 @@ +"""Test for the alarm control panel const module.""" + +from types import ModuleType + +import pytest + +from homeassistant.components import alarm_control_panel + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize( + "code_format", + list(alarm_control_panel.CodeFormat), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_constant_code_format( + caplog: pytest.LogCaptureFixture, + code_format: alarm_control_panel.CodeFormat, + module: ModuleType, +) -> None: + """Test deprecated format constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, code_format, "FORMAT_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(alarm_control_panel.AlarmControlPanelEntityFeature), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_support_alarm_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated support alarm constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" + ) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index a2f2dfbf907..235ca48f095 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2582,7 +2582,7 @@ def test_deprecated_constants( constant_name: str, replacement: Any, ) -> None: - """Test deprecated binary sensor device classes.""" + """Test deprecated automation constants.""" import_and_test_deprecated_constant( caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" ) diff --git a/tests/testing_config/custom_components/test/alarm_control_panel.py b/tests/testing_config/custom_components/test/alarm_control_panel.py index b39c2c71eda..7490a7703a4 100644 --- a/tests/testing_config/custom_components/test/alarm_control_panel.py +++ b/tests/testing_config/custom_components/test/alarm_control_panel.py @@ -4,11 +4,7 @@ Call init before using it in your tests to ensure clean test data. """ from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, + AlarmControlPanelEntityFeature, ) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -73,14 +69,14 @@ class MockAlarm(MockEntity, AlarmControlPanelEntity): return self._state @property - def supported_features(self) -> int: + def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" return ( - SUPPORT_ALARM_ARM_HOME - | SUPPORT_ALARM_ARM_AWAY - | SUPPORT_ALARM_ARM_NIGHT - | SUPPORT_ALARM_TRIGGER - | SUPPORT_ALARM_ARM_VACATION + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_VACATION ) def alarm_arm_away(self, code=None): From 803e77bebd47952ce155cad7039115c2c61cfa40 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 19:10:12 +0100 Subject: [PATCH 553/927] Move prusalink migration to async_migrate_entry and use a minor version bump (#106109) Co-authored-by: Martin Hjelmare --- .../components/prusalink/__init__.py | 120 +++++++++--------- .../components/prusalink/config_flow.py | 3 +- tests/components/prusalink/conftest.py | 3 +- tests/components/prusalink/test_init.py | 53 +++++--- 4 files changed, 102 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 98dc7cb47ae..b6a00bbaf10 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -29,73 +30,24 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) +from .config_flow import ConfigFlow from .const import DOMAIN PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def _migrate_to_version_2( - hass: HomeAssistant, entry: ConfigEntry -) -> PrusaLink | None: - """Migrate to Version 2.""" - _LOGGER.debug("Migrating entry to version 2") - - data = dict(entry.data) - # "maker" is currently hardcoded in the firmware - # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 - data = { - **entry.data, - CONF_USERNAME: "maker", - CONF_PASSWORD: entry.data[CONF_API_KEY], - } - data.pop(CONF_API_KEY) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PrusaLink from a config entry.""" + if entry.version == 1 and entry.minor_version < 2: + raise ConfigEntryError("Please upgrade your printer's firmware.") api = PrusaLink( async_get_clientsession(hass), - data[CONF_HOST], - data[CONF_USERNAME], - data[CONF_PASSWORD], + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) - try: - await api.get_info() - except InvalidAuth: - # We are unable to reach the new API which usually means - # that the user is running an outdated firmware version - ir.async_create_issue( - hass, - DOMAIN, - "firmware_5_1_required", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="firmware_5_1_required", - translation_placeholders={ - "entry_title": entry.title, - "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", - "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", - }, - ) - return None - - entry.version = 2 - hass.config_entries.async_update_entry(entry, data=data) - _LOGGER.info("Migrated config entry to version %d", entry.version) - return api - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PrusaLink from a config entry.""" - if entry.version == 1: - if (api := await _migrate_to_version_2(hass, entry)) is None: - return False - ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") - else: - api = PrusaLink( - async_get_clientsession(hass), - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) coordinators = { "legacy_status": LegacyStatusCoordinator(hass, api), @@ -112,9 +64,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - # Version 1->2 migration are handled in async_setup_entry. + if config_entry.version > ConfigFlow.VERSION: + # This means the user has downgraded from a future version + return False + + new_data = dict(config_entry.data) + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Add username and password + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + username = "maker" + password = config_entry.data[CONF_API_KEY] + + api = PrusaLink( + async_get_clientsession(hass), + config_entry.data[CONF_HOST], + username, + password, + ) + try: + await api.get_info() + except InvalidAuth: + # We are unable to reach the new API which usually means + # that the user is running an outdated firmware version + ir.async_create_issue( + hass, + DOMAIN, + "firmware_5_1_required", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="firmware_5_1_required", + translation_placeholders={ + "entry_title": config_entry.title, + "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", + "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", + }, + ) + # There is a check in the async_setup_entry to prevent the setup if minor_version < 2 + # Currently we can't reload the config entry + # if the migration returns False. + # Return True here to workaround that. + return True + + new_data[CONF_USERNAME] = username + new_data[CONF_PASSWORD] = password + + ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") + config_entry.minor_version = 2 + + hass.config_entries.async_update_entry(config_entry, data=new_data) + return True diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index e967cefaffd..378c5e7395a 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -66,7 +66,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PrusaLink.""" - VERSION = 2 + VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 97f4bd92d7d..1e514342068 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -14,7 +14,8 @@ def mock_config_entry(hass): entry = MockConfigEntry( domain=DOMAIN, data={"host": "http://example.com", "username": "dummy", "password": "dummypw"}, - version=2, + version=1, + minor_version=2, ) entry.add_to_hass(hass) return entry diff --git a/tests/components/prusalink/test_init.py b/tests/components/prusalink/test_init.py index 963750ef8be..5b261207e93 100644 --- a/tests/components/prusalink/test_init.py +++ b/tests/components/prusalink/test_init.py @@ -6,6 +6,7 @@ from pyprusalink.types import InvalidAuth, PrusaLinkError import pytest from homeassistant.components.prusalink import DOMAIN +from homeassistant.components.prusalink.config_flow import ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -14,11 +15,12 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +pytestmark = pytest.mark.usefixtures("mock_api") + async def test_unloading( hass: HomeAssistant, mock_config_entry: ConfigEntry, - mock_api, ) -> None: """Test unloading prusalink.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -35,7 +37,7 @@ async def test_unloading( @pytest.mark.parametrize("exception", [InvalidAuth, PrusaLinkError]) async def test_failed_update( - hass: HomeAssistant, mock_config_entry: ConfigEntry, mock_api, exception + hass: HomeAssistant, mock_config_entry: ConfigEntry, exception ) -> None: """Test failed update marks prusalink unavailable.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -61,16 +63,17 @@ async def test_failed_update( assert state.state == "unavailable" -async def test_migration_1_2( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +async def test_migration_from_1_1_to_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test migrating from version 1 to 2.""" + data = { + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + } entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "http://prusaxl.local", - CONF_API_KEY: "api-key", - }, + data=data, version=1, ) entry.add_to_hass(hass) @@ -83,7 +86,7 @@ async def test_migration_1_2( # Ensure that we have username, password after migration assert len(config_entries) == 1 assert config_entries[0].data == { - CONF_HOST: "http://prusaxl.local", + **data, CONF_USERNAME: "maker", CONF_PASSWORD: "api-key", } @@ -91,10 +94,10 @@ async def test_migration_1_2( assert len(issue_registry.issues) == 0 -async def test_outdated_firmware_migration_1_2( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api +async def test_migration_from_1_1_to_1_2_outdated_firmware( + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: - """Test migrating from version 1 to 2.""" + """Test migrating from version 1.1 to 1.2.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -107,14 +110,14 @@ async def test_outdated_firmware_migration_1_2( with patch( "pyprusalink.PrusaLink.get_info", - side_effect=InvalidAuth, + side_effect=InvalidAuth, # Simulate firmware update required ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.SETUP_ERROR - # Make sure that we don't have thrown the issues - assert len(issue_registry.issues) == 1 + assert entry.minor_version == 1 + assert (DOMAIN, "firmware_5_1_required") in issue_registry.issues # Reloading the integration with a working API (e.g. User updated firmware) await hass.config_entries.async_reload(entry.entry_id) @@ -122,4 +125,22 @@ async def test_outdated_firmware_migration_1_2( # Integration should be running now, the issue should be gone assert entry.state == ConfigEntryState.LOADED - assert len(issue_registry.issues) == 0 + assert entry.minor_version == 2 + assert (DOMAIN, "firmware_5_1_required") not in issue_registry.issues + + +async def test_migration_fails_on_future_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating fails on a version higher than the current one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + version=ConfigFlow.VERSION + 1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.MIGRATION_ERROR From 98f0ed1892598bf43d4f7ceab84c8f710541f301 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 20 Dec 2023 19:11:03 +0100 Subject: [PATCH 554/927] Deprecate deprecated siren constants (#106121) --- homeassistant/components/siren/__init__.py | 21 +++++++++++---- homeassistant/components/siren/const.py | 31 ++++++++++++++++++---- tests/components/siren/test_init.py | 15 +++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index d7e8843f54b..37bab7a995d 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging from typing import Any, TypedDict, cast, final @@ -15,21 +16,25 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_SUPPORT_DURATION, + _DEPRECATED_SUPPORT_TONES, + _DEPRECATED_SUPPORT_TURN_OFF, + _DEPRECATED_SUPPORT_TURN_ON, + _DEPRECATED_SUPPORT_VOLUME_SET, ATTR_AVAILABLE_TONES, ATTR_DURATION, ATTR_TONE, ATTR_VOLUME_LEVEL, DOMAIN, - SUPPORT_DURATION, - SUPPORT_TONES, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SirenEntityFeature, ) @@ -43,6 +48,12 @@ TURN_ON_SCHEMA = { vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, } +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + class SirenTurnOnServiceParameters(TypedDict, total=False): """Represent possible parameters to siren.turn_on service data dict type.""" diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 374b1d59e2a..50c3af61c8d 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -1,8 +1,15 @@ """Constants for the siren component.""" from enum import IntFlag +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "siren" ATTR_TONE: Final = "tone" @@ -24,8 +31,22 @@ class SirenEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the SirenEntityFeature enum instead. -SUPPORT_TURN_ON: Final = 1 -SUPPORT_TURN_OFF: Final = 2 -SUPPORT_TONES: Final = 4 -SUPPORT_VOLUME_SET: Final = 8 -SUPPORT_DURATION: Final = 16 +_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_ON, "2025.1" +) +_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum( + SirenEntityFeature.TONES, "2025.1" +) +_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum( + SirenEntityFeature.VOLUME_SET, "2025.1" +) +_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( + SirenEntityFeature.DURATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 267b1c1e30d..ee007f6f1f5 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -1,8 +1,10 @@ """The tests for the siren component.""" +from types import ModuleType from unittest.mock import MagicMock import pytest +from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -11,6 +13,8 @@ from homeassistant.components.siren import ( from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockSirenEntity(SirenEntity): """Mock siren device to use in tests.""" @@ -104,3 +108,14 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) + + +@pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) +@pytest.mark.parametrize(("module"), [siren, siren.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SirenEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") From 69dcc159ae388eaa1a6b807d670f81102992536e Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 20 Dec 2023 20:32:03 +0100 Subject: [PATCH 555/927] Bump pyatmo to 8.0.1 (#106094) Fix missing NLFE --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 3860c70bbea..f5f2d67947f 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.0"] + "requirements": ["pyatmo==8.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 681dffea86f..0a13f0f109b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1637,7 +1637,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.0 +pyatmo==8.0.1 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 874b2d3200d..fbf46da4cbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1253,7 +1253,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.0 +pyatmo==8.0.1 # homeassistant.components.apple_tv pyatv==0.14.3 From 65d3f7e1c73ebb4add1b9ca8758c02d24155bedc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 21:18:30 +0100 Subject: [PATCH 556/927] Improve error mqtt valve error logging (#106129) * Improve error mqtt valve error logging * Update homeassistant/components/mqtt/valve.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/valve.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/valve.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/valve.py | 44 +++++++++++++++----------- tests/components/mqtt/test_valve.py | 1 + 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 2c1618c60ba..66c73b91859 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -66,12 +66,7 @@ from .mixins import ( async_setup_entity_entry_helper, write_state_on_attr_change, ) -from .models import ( - MqttCommandTemplate, - MqttValueTemplate, - ReceiveMessage, - ReceivePayloadType, -) +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -230,7 +225,7 @@ class MqttValve(MqttEntity, ValveEntity): @callback def _process_binary_valve_update( - self, payload: ReceivePayloadType, state_payload: str + self, msg: ReceiveMessage, state_payload: str ) -> None: """Process an update for a valve that does not report the position.""" state: str | None = None @@ -244,18 +239,21 @@ class MqttValve(MqttEntity, ValveEntity): state = STATE_CLOSED if state is None: _LOGGER.warning( - "Payload is not one of [open, closed, opening, closing], got: %s", - payload, + "Payload received on topic '%s' is not one of " + "[open, closed, opening, closing], got: %s", + msg.topic, + state_payload, ) return self._update_state(state) @callback def _process_position_valve_update( - self, payload: ReceivePayloadType, position_payload: str, state_payload: str + self, msg: ReceiveMessage, position_payload: str, state_payload: str ) -> None: """Process an update for a valve that reports the position.""" state: str | None = None + position_set: bool = False if state_payload == self._config[CONF_STATE_OPENING]: state = STATE_OPENING elif state_payload == self._config[CONF_STATE_CLOSING]: @@ -266,16 +264,24 @@ class MqttValve(MqttEntity, ValveEntity): self._range, float(position_payload) ) except ValueError: - _LOGGER.warning("Payload '%s' is not numeric", position_payload) - return - - self._attr_current_valve_position = min(max(percentage_payload, 0), 100) - if state is None: + _LOGGER.warning( + "Ignoring non numeric payload '%s' received on topic '%s'", + position_payload, + msg.topic, + ) + else: + self._attr_current_valve_position = min(max(percentage_payload, 0), 100) + position_set = True + if state_payload and state is None and not position_set: _LOGGER.warning( - "Payload is not one of [opening, closing], got: %s", - payload, + "Payload received on topic '%s' is not one of " + "[opening, closing], got: %s", + msg.topic, + state_payload, ) return + if state is None: + return self._update_state(state) def _prepare_subscribe_topics(self) -> None: @@ -315,10 +321,10 @@ class MqttValve(MqttEntity, ValveEntity): if self._config[CONF_REPORTS_POSITION]: self._process_position_valve_update( - payload, position_payload, state_payload + msg, position_payload, state_payload ) else: - self._process_binary_valve_update(payload, state_payload) + self._process_binary_valve_update(msg, state_payload) if self._config.get(CONF_STATE_TOPIC): topics["state_topic"] = { diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 27be72ecabc..04ae0cf50e6 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -245,6 +245,7 @@ async def test_state_via_state_topic_with_position_template( @pytest.mark.parametrize( ("message", "asserted_state", "valve_position"), [ + ("invalid", STATE_UNKNOWN, None), ("0", STATE_CLOSED, 0), ("opening", STATE_OPENING, None), ("50", STATE_OPEN, 50), From c57cc8517431907c533a1970259474dc285374ea Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 23:07:31 +0100 Subject: [PATCH 557/927] Add note on overkiz measurement sensor fix (#105141) --- homeassistant/components/overkiz/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 011daf2ab51..3f1de4c381e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -484,6 +484,10 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): if ( state is None or state.value is None + # It seems that in some cases we return `None` if state.value is falsy. + # This is probably incorrect and should be fixed in a follow up PR. + # To ensure measurement sensors do not get an `unknown` state on + # a falsy value (e.g. 0 or 0.0) we also check the state_class. or self.state_class != SensorStateClass.MEASUREMENT and not state.value ): From f5f9b898480c051c7dd956b12428e3b099f67e61 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 23:26:55 +0100 Subject: [PATCH 558/927] Add water_heater to google_assistant (#105915) * Add water_heater to google_assistant * Follow up comments * Add water_heater to default exposed domains --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 140 +++++++++- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 239 ++++++++++++++++++ 4 files changed, 373 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 060f7ce50e5..70bdc37df66 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,6 +22,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) DOMAIN = "google_assistant" @@ -64,6 +65,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "sensor", "switch", "vacuum", + "water_heater", ] # https://developers.google.com/assistant/smarthome/guides @@ -93,6 +95,7 @@ TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_WATERHEATER = f"{PREFIX_TYPES}WATERHEATER" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" @@ -147,6 +150,7 @@ DOMAIN_TO_GOOGLE_TYPES = { sensor.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + water_heater.DOMAIN: TYPE_WATERHEATER, } DEVICE_CLASS_TO_GOOGLE_TYPES = { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 33f0d7a3329..2e861f16a02 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -29,6 +29,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -40,6 +41,7 @@ from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -139,6 +141,7 @@ COMMAND_PAUSEUNPAUSE = f"{PREFIX_COMMANDS}PauseUnpause" COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute" COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute" COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene" +COMMAND_SET_TEMPERATURE = f"{PREFIX_COMMANDS}SetTemperature" COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( f"{PREFIX_COMMANDS}ThermostatTemperatureSetpoint" ) @@ -417,6 +420,9 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, @@ -894,38 +900,97 @@ class StartStopTrait(_Trait): @register_trait class TemperatureControlTrait(_Trait): - """Trait for devices (other than thermostats) that support controlling temperature. Workaround for Temperature sensors. + """Trait for devices (other than thermostats) that support controlling temperature. + + Control the target temperature of water heaters. + Offers a workaround for Temperature sensors by setting queryOnlyTemperatureControl + in the response. https://developers.google.com/assistant/smarthome/traits/temperaturecontrol """ name = TRAIT_TEMPERATURE_CONTROL + commands = [ + COMMAND_SET_TEMPERATURE, + ] + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" return ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.TARGET_TEMPERATURE + ) or ( domain == sensor.DOMAIN and device_class == sensor.SensorDeviceClass.TEMPERATURE ) def sync_attributes(self): """Return temperature attributes for a sync request.""" - return { - "temperatureUnitForUX": _google_temp_unit( - self.hass.config.units.temperature_unit - ), - "queryOnlyTemperatureControl": True, - "temperatureRange": { + response = {} + domain = self.state.domain + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["temperatureUnitForUX"] = _google_temp_unit(unit) + + if domain == water_heater.DOMAIN: + min_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["temperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } + else: + response["queryOnlyTemperatureControl"] = True + response["temperatureRange"] = { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, - }, - } + } + + return response def query_attributes(self): """Return temperature states.""" response = {} + domain = self.state.domain unit = self.hass.config.units.temperature_unit + if domain == water_heater.DOMAIN: + target_temp = self.state.attributes[water_heater.ATTR_TEMPERATURE] + current_temp = self.state.attributes[water_heater.ATTR_CURRENT_TEMPERATURE] + if target_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["temperatureSetpointCelsius"] = round( + TemperatureConverter.convert( + float(target_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + if current_temp is not None: + response["temperatureAmbientCelsius"] = round( + TemperatureConverter.convert( + float(current_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + return response + + # domain == sensor.DOMAIN current_temp = self.state.state if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): temp = round( @@ -940,8 +1005,35 @@ class TemperatureControlTrait(_Trait): return response async def execute(self, command, data, params, challenge): - """Unsupported.""" - raise SmartHomeError(ERR_NOT_SUPPORTED, "Execute is not supported by sensor") + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + domain = self.state.domain + unit = self.hass.config.units.temperature_unit + + if domain == water_heater.DOMAIN and command == COMMAND_SET_TEMPERATURE: + min_temp = self.state.attributes[water_heater.ATTR_MIN_TEMP] + max_temp = self.state.attributes[water_heater.ATTR_MAX_TEMP] + temp = TemperatureConverter.convert( + params["temperature"], UnitOfTemperature.CELSIUS, unit + ) + if unit == UnitOfTemperature.FAHRENHEIT: + temp = round(temp) + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + f"Temperature should be between {min_temp} and {max_temp}", + ) + + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + + raise SmartHomeError(ERR_NOT_SUPPORTED, f"Execute is not supported by {domain}") @register_trait @@ -1696,6 +1788,12 @@ class ModesTrait(_Trait): if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True + if ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.OPERATION_MODE + ): + return True + if domain != media_player.DOMAIN: return False @@ -1736,6 +1834,7 @@ class ModesTrait(_Trait): (select.DOMAIN, select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), + (water_heater.DOMAIN, water_heater.ATTR_OPERATION_LIST, "operation mode"), ): if self.state.domain != domain: continue @@ -1769,6 +1868,11 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) + elif self.state.domain == water_heater.DOMAIN: + if water_heater.ATTR_OPERATION_MODE in attrs: + mode_settings["operation mode"] = attrs.get( + water_heater.ATTR_OPERATION_MODE + ) elif self.state.domain == light.DOMAIN and ( effect := attrs.get(light.ATTR_EFFECT) ): @@ -1840,6 +1944,20 @@ class ModesTrait(_Trait): ) return + if self.state.domain == water_heater.DOMAIN: + requested_mode = settings["operation mode"] + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_OPERATION_MODE, + { + water_heater.ATTR_OPERATION_MODE: requested_mode, + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + if self.state.domain == light.DOMAIN: requested_effect = settings["effect"] await self.hass.services.async_call( diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 663979eda77..e29b4d5f487 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -103,6 +103,7 @@ 'sensor', 'switch', 'vacuum', + 'water_heater', ]), 'project_id': '1234', 'report_state': False, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 903ba5ca036..293b16e637a 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -27,6 +27,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -44,6 +45,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -75,6 +77,7 @@ from homeassistant.core import ( State, ) from homeassistant.util import color +from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -393,6 +396,35 @@ async def test_onoff_humidifier(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} +async def test_onoff_water_heater(hass: HomeAssistant) -> None: + """Test OnOff trait support for water_heater domain.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.OnOffTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.ON_OFF, None, None + ) + + trt_on = trait.OnOffTrait(hass, State("water_heater.bla", STATE_ON), BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == {"on": True} + + trt_off = trait.OnOffTrait(hass, State("water_heater.bla", STATE_OFF), BASIC_CONFIG) + + assert trt_off.query_attributes() == {"on": False} + + on_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": True}, {}) + assert len(on_calls) == 1 + assert on_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + off_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": False}, {}) + assert len(off_calls) == 1 + assert off_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + async def test_dock_vacuum(hass: HomeAssistant) -> None: """Test dock trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -1246,6 +1278,135 @@ async def test_temperature_control(hass: HomeAssistant) -> None: assert err.value.code == const.ERR_NOT_SUPPORTED +@pytest.mark.parametrize( + ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), + [ + (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), + (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + ], +) +async def test_temperature_control_water_heater( + hass: HomeAssistant, + unit_in: UnitOfTemperature, + unit_out: str, + temp_in: str, + temp_out: float, + current_in: str, + current_out: float, +) -> None: + """Test TemperatureControl trait support for water heater domain.""" + hass.config.units.temperature_unit = unit_in + + min_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MIN_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + max_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MAX_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_in, + "current_temperature": current_in, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "temperatureUnitForUX": unit_out, + "temperatureRange": { + "maxThresholdCelsius": water_heater.DEFAULT_MAX_TEMP, + "minThresholdCelsius": water_heater.DEFAULT_MIN_TEMP, + }, + } + assert trt.query_attributes() == { + "temperatureSetpointCelsius": temp_out, + "temperatureAmbientCelsius": current_out, + } + + +@pytest.mark.parametrize( + ("unit", "temp_init", "temp_in", "temp_out", "current_init"), + [ + (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), + (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + ], +) +async def test_temperature_control_water_heater_set_temperature( + hass: HomeAssistant, + unit: UnitOfTemperature, + temp_init: str, + temp_in: float, + temp_out: float, + current_init: str, +) -> None: + """Test TemperatureControl trait support for water heater domain - SetTemperature.""" + hass.config.units.temperature_unit = unit + + min_temp = TemperatureConverter.convert( + 40, + UnitOfTemperature.CELSIUS, + unit, + ) + max_temp = TemperatureConverter.convert( + 230, + UnitOfTemperature.CELSIUS, + unit, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_init, + "current_temperature": current_init, + }, + ), + BASIC_CONFIG, + ) + + assert trt.can_execute(trait.COMMAND_SET_TEMPERATURE, {}) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_TEMPERATURE + ) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": -100}, + {}, + ) + + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": temp_in}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "water_heater.bla", + ATTR_TEMPERATURE: temp_out, + } + + async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None: """Test HumiditySetting trait support for humidifier domain - setpoint.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None @@ -2411,6 +2572,84 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: } +async def test_modes_water_heater(hass: HomeAssistant) -> None: + """Test Humidifier Mode trait.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.ModesTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.OPERATION_MODE, None, None + ) + + trt = trait.ModesTrait( + hass, + State( + "water_heater.water_heater", + STATE_OFF, + attributes={ + water_heater.ATTR_OPERATION_LIST: [ + water_heater.STATE_ECO, + water_heater.STATE_HEAT_PUMP, + water_heater.STATE_GAS, + ], + ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.OPERATION_MODE, + water_heater.ATTR_OPERATION_MODE: water_heater.STATE_HEAT_PUMP, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "operation mode", + "name_values": [{"name_synonym": ["operation mode"], "lang": "en"}], + "settings": [ + { + "setting_name": "eco", + "setting_values": [{"setting_synonym": ["eco"], "lang": "en"}], + }, + { + "setting_name": "heat_pump", + "setting_values": [ + {"setting_synonym": ["heat_pump"], "lang": "en"} + ], + }, + { + "setting_name": "gas", + "setting_values": [{"setting_synonym": ["gas"], "lang": "en"}], + }, + ], + "ordered": False, + }, + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"operation mode": "heat_pump"}, + "on": False, + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={"updateModeSettings": {"operation mode": "gas"}} + ) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_OPERATION_MODE + ) + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {"operation mode": "gas"}}, + {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "water_heater.water_heater", + "operation_mode": "gas", + } + + async def test_sound_modes(hass: HomeAssistant) -> None: """Test Mode trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None From 24b1e01d712d31022b1d388a03262167b26231e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 21 Dec 2023 00:55:09 +0200 Subject: [PATCH 559/927] Update Ruff to 0.1.8, avoid linter/formatter conflicts (#106080) * Disable Ruff rules that may conflict with the formatter * Upgrade Ruff to 0.1.8 - https://github.com/astral-sh/ruff/releases/tag/v0.1.7 - https://github.com/astral-sh/ruff/releases/tag/v0.1.8 * Format with Ruff 0.1.8 --- .pre-commit-config.yaml | 2 +- .../components/androidtv/media_player.py | 2 +- .../components/braviatv/coordinator.py | 2 +- homeassistant/components/calendar/__init__.py | 4 +-- homeassistant/components/cloud/http_api.py | 4 ++- homeassistant/components/decora/light.py | 2 +- .../components/device_automation/__init__.py | 2 +- homeassistant/components/dhcp/__init__.py | 2 +- .../components/dlna_dmr/media_player.py | 2 +- homeassistant/components/dlna_dms/dms.py | 2 +- homeassistant/components/duotecno/entity.py | 2 +- .../components/dynalite/convert_config.py | 2 +- homeassistant/components/esphome/entity.py | 2 +- .../components/evil_genius_labs/util.py | 2 +- .../config_flow.py | 2 +- homeassistant/components/group/sensor.py | 16 ++++----- homeassistant/components/guardian/__init__.py | 2 +- .../components/hassio/addon_manager.py | 2 +- homeassistant/components/hive/__init__.py | 2 +- homeassistant/components/homekit/__init__.py | 2 +- .../components/homewizard/helpers.py | 2 +- homeassistant/components/http/ban.py | 2 +- homeassistant/components/http/decorators.py | 2 +- .../components/iaqualink/__init__.py | 2 +- homeassistant/components/iqvia/__init__.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/lametric/helpers.py | 2 +- .../components/lutron_caseta/__init__.py | 2 +- .../components/motioneye/__init__.py | 2 +- homeassistant/components/mqtt/mixins.py | 2 +- homeassistant/components/nina/coordinator.py | 2 +- .../components/openhome/media_player.py | 2 +- homeassistant/components/otbr/util.py | 2 +- .../components/overkiz/alarm_control_panel.py | 6 ++-- homeassistant/components/plex/media_player.py | 2 +- homeassistant/components/plugwise/util.py | 2 +- .../components/rainmachine/__init__.py | 2 +- .../components/rainmachine/switch.py | 2 +- .../components/recorder/statistics.py | 2 +- homeassistant/components/recorder/util.py | 2 +- .../components/renault/renault_vehicle.py | 2 +- homeassistant/components/rfxtrx/__init__.py | 4 +-- homeassistant/components/roku/helpers.py | 2 +- homeassistant/components/sabnzbd/__init__.py | 2 +- homeassistant/components/sensibo/entity.py | 2 +- homeassistant/components/sfr_box/button.py | 2 +- .../components/simplisafe/__init__.py | 2 +- homeassistant/components/sonos/diagnostics.py | 2 +- .../components/synology_dsm/config_flow.py | 2 +- homeassistant/components/tplink/entity.py | 2 +- .../components/tradfri/base_class.py | 2 +- .../components/velbus/diagnostics.py | 2 +- homeassistant/components/velbus/entity.py | 2 +- homeassistant/components/vera/config_flow.py | 2 +- .../components/vlc_telnet/media_player.py | 2 +- .../components/wallbox/coordinator.py | 2 +- .../components/webostv/media_player.py | 2 +- .../components/websocket_api/commands.py | 2 +- homeassistant/components/wled/helpers.py | 2 +- homeassistant/components/zeroconf/__init__.py | 2 +- homeassistant/components/zwave_js/helpers.py | 2 +- homeassistant/components/zwave_js/services.py | 2 +- homeassistant/config.py | 4 +-- homeassistant/helpers/entityfilter.py | 2 +- homeassistant/helpers/event.py | 2 +- homeassistant/helpers/service.py | 2 +- homeassistant/helpers/translation.py | 2 +- homeassistant/util/logging.py | 2 +- pyproject.toml | 16 ++++++++- requirements_test_pre_commit.txt | 2 +- tests/common.py | 2 +- tests/components/cast/test_media_player.py | 7 ++-- tests/components/esphome/conftest.py | 4 +-- tests/components/fronius/__init__.py | 2 +- tests/components/google/conftest.py | 2 +- .../homekit_controller/test_climate.py | 6 ++-- .../components/homekit_controller/test_fan.py | 2 +- tests/components/hydrawise/conftest.py | 2 +- .../components/improv_ble/test_config_flow.py | 4 +-- tests/components/picnic/conftest.py | 2 +- tests/components/rainbird/test_calendar.py | 2 +- tests/components/recorder/test_history.py | 32 ++++++++--------- .../recorder/test_history_db_schema_30.py | 34 +++++++++---------- .../recorder/test_history_db_schema_32.py | 32 ++++++++--------- tests/components/recorder/test_init.py | 34 +++++++++---------- tests/components/recorder/test_statistics.py | 2 +- tests/components/recorder/test_util.py | 2 +- tests/components/samsungtv/conftest.py | 4 +-- tests/components/zha/test_cluster_handlers.py | 2 +- tests/conftest.py | 2 +- tests/helpers/test_deprecation.py | 2 +- 91 files changed, 188 insertions(+), 173 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae135f30407..79bf7e87903 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.8 hooks: - id: ruff args: diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 1fec605d8e1..496b4e51e4f 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -160,7 +160,7 @@ def adb_decorator( """ def _adb_decorator( - func: _FuncType[_ADBDeviceT, _P, _R] + func: _FuncType[_ADBDeviceT, _P, _R], ) -> _ReturnFuncType[_ADBDeviceT, _P, _R]: """Wrap the provided ADB method and catch exceptions.""" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 43f911cd3a2..59219a34eb7 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -42,7 +42,7 @@ SCAN_INTERVAL: Final = timedelta(seconds=10) def catch_braviatv_errors( - func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]] + func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]], ) -> Callable[Concatenate[_BraviaTVCoordinatorT, _P], Coroutine[Any, Any, None]]: """Catch Bravia errors.""" diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 5b98d372220..41e13b798b6 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -429,7 +429,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: def _list_events_dict_factory( - obj: Iterable[tuple[str, Any]] + obj: Iterable[tuple[str, Any]], ) -> dict[str, JsonValueType]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" return { @@ -818,7 +818,7 @@ async def handle_calendar_event_update( def _validate_timespan( - values: dict[str, Any] + values: dict[str, Any], ) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: """Parse a create event service call and convert the args ofr a create event entity call. diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index c937a415cda..d01b0c29e06 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -116,7 +116,9 @@ _P = ParamSpec("_P") def _handle_cloud_errors( - handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] + handler: Callable[ + Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response] + ], ) -> Callable[ Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] ]: diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index d060b69c3f6..4a56b72ec66 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -60,7 +60,7 @@ PLATFORM_SCHEMA = vol.Schema( def retry( - method: Callable[Concatenate[_DecoraLightT, _P], _R] + method: Callable[Concatenate[_DecoraLightT, _P], _R], ) -> Callable[Concatenate[_DecoraLightT, _P], _R | None]: """Retry bluetooth commands.""" diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index d7641c34316..68d05c19f67 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -358,7 +358,7 @@ def async_validate_entity_schema( def handle_device_errors( - func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] + func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]], ) -> Callable[ [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] ]: diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index c3705dad3dd..df95e629b8f 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -489,7 +489,7 @@ class DHCPWatcher(WatcherBase): def _dhcp_options_as_dict( - dhcp_options: Iterable[tuple[str, int | bytes | None]] + dhcp_options: Iterable[tuple[str, int | bytes | None]], ) -> dict[str, str | int | bytes | None]: """Extract data from packet options as a dict.""" return {option[0]: option[1] for option in dhcp_options if len(option) >= 2} diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index cd2f1ae2f50..749f2c887eb 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -55,7 +55,7 @@ _P = ParamSpec("_P") def catch_request_errors( - func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R | None]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 6352d98da3c..62ff2be7d5b 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -124,7 +124,7 @@ class ActionError(DlnaDmsDeviceError): def catch_request_errors( - func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]] + func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]], ) -> Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index d38d52a0d26..8d905979bfe 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -47,7 +47,7 @@ _P = ParamSpec("_P") def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 4abc02c0565..25d18dd92e8 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -138,7 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any] + config: dict[str, Any] | MappingProxyType[str, Any], ) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index dc5a4ff0968..1def6d37e02 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -92,7 +92,7 @@ async def platform_async_setup_entry( def esphome_state_property( - func: Callable[[_EntityT], _R] + func: Callable[[_EntityT], _R], ) -> Callable[[_EntityT], _R | None]: """Wrap a state property of an esphome entity. diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index b0e01c1f329..eb2caf59d9d 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -13,7 +13,7 @@ _P = ParamSpec("_P") def update_when_done( - func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_EvilGeniusEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate function to trigger update when function is done.""" diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 94639177a42..fea023c604e 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -129,7 +129,7 @@ class OptionsFlow(config_entries.OptionsFlow): def google_generative_ai_config_option_schema( - options: MappingProxyType[str, Any] + options: MappingProxyType[str, Any], ) -> dict: """Return a schema for Google Generative AI completion options.""" if not options: diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 10030ab647f..c35c96d38aa 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -154,7 +154,7 @@ def async_create_preview_sensor( def calc_min( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate min value.""" val: float | None = None @@ -170,7 +170,7 @@ def calc_min( def calc_max( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate max value.""" val: float | None = None @@ -186,7 +186,7 @@ def calc_max( def calc_mean( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate mean value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -196,7 +196,7 @@ def calc_mean( def calc_median( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate median value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -206,7 +206,7 @@ def calc_median( def calc_last( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate last value.""" last_updated: datetime | None = None @@ -223,7 +223,7 @@ def calc_last( def calc_range( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate range value.""" max_result = max((sensor_value for _, sensor_value, _ in sensor_values)) @@ -234,7 +234,7 @@ def calc_range( def calc_sum( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a sum of values.""" result = 0.0 @@ -245,7 +245,7 @@ def calc_sum( def calc_product( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a product of values.""" result = 1.0 diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 1cb55204240..4a394692dd8 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -171,7 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def call_with_data( - func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]] + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 22265f49912..7f9299fa2b1 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -43,7 +43,7 @@ def api_error( """Handle HassioAPIError and raise a specific AddonError.""" def handle_hassio_api_error( - func: _FuncType[_AddonManagerT, _P, _R] + func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: """Handle a HassioAPIError.""" diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index ba060caa43a..1a386d3b271 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -131,7 +131,7 @@ async def async_remove_config_entry_device( def refresh_system( - func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0920530524d..cd90c4acf60 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -152,7 +152,7 @@ _HOMEKIT_CONFIG_UPDATE_TIME = ( def _has_all_unique_names_and_ports( - bridges: list[dict[str, Any]] + bridges: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Validate that each homekit bridge configured has a unique name.""" names = [bridge[CONF_NAME] for bridge in bridges] diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 4f12a4f9726..4c3ae76a327 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -16,7 +16,7 @@ _P = ParamSpec("_P") def homewizard_exception_handler( - func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]: """Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions. diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index c56dd6c343b..62569495ba7 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -84,7 +84,7 @@ async def ban_middleware( def log_invalid_auth( - func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]] + func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]], ) -> Callable[Concatenate[_HassViewT, Request, _P], Coroutine[Any, Any, Response]]: """Decorate function to handle invalid auth or failed login attempts.""" diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index ce5b1b18c06..4d8ac5c2df5 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -45,7 +45,7 @@ def require_admin( """Home Assistant API decorator to require user to be an admin.""" def decorator_require_admin( - func: _FuncType[_HomeAssistantViewT, _P] + func: _FuncType[_HomeAssistantViewT, _P], ) -> _FuncType[_HomeAssistantViewT, _P]: """Wrap the provided with_admin function.""" diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index fceb0d72213..062548666c4 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -185,7 +185,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def refresh_system( - func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_AqualinkEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index def58d60201..aa5528cc06a 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.disable_request_retries() async def async_get_data_from_api( - api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]] + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], ) -> dict[str, Any]: """Get data from a particular API coroutine.""" try: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 32ecbbed626..89f0a992ff1 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -231,7 +231,7 @@ async def async_setup_entry( def cmd( - func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_KodiEntityT, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 884e6c451bc..3a3014a369e 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -19,7 +19,7 @@ _P = ParamSpec("_P") def lametric_exception_handler( - func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, None]]: """Decorate LaMetric calls to handle LaMetric exceptions. diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 41369046d51..0788af76aca 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -322,7 +322,7 @@ def _async_setup_keypads( @callback def _async_build_trigger_schemas( - keypad_button_names_to_leap: dict[int, dict[str, int]] + keypad_button_names_to_leap: dict[int, dict[str, int]], ) -> dict[int, vol.Schema]: """Build device trigger schemas.""" diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 59fc41df9b0..37519a236ab 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -111,7 +111,7 @@ def get_motioneye_device_identifier( def split_motioneye_device_identifier( - identifier: tuple[str, str] + identifier: tuple[str, str], ) -> tuple[str, str, int] | None: """Get the identifiers for a motionEye device.""" if len(identifier) != 2 or identifier[0] != DOMAIN or "_" not in identifier[1]: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index ded9073ac57..412664ceedf 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1135,7 +1135,7 @@ class MqttDiscoveryUpdate(Entity): def device_info_from_specifications( - specifications: dict[str, Any] | None + specifications: dict[str, Any] | None, ) -> DeviceInfo | None: """Return a device description for device registry.""" if not specifications: diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index eb5c7a7e506..b2c97503442 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -66,7 +66,7 @@ class NINADataUpdateCoordinator( @staticmethod def _remove_duplicate_warnings( - warnings: dict[str, list[Any]] + warnings: dict[str, list[Any]], ) -> dict[str, list[Any]]: """Remove warnings with the same title and expires timestamp in a region.""" all_filtered_warnings: dict[str, list[Any]] = {} diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 51d7774a2fb..a4a16c6713c 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -79,7 +79,7 @@ def catch_request_errors() -> ( """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( - func: _FuncType[_OpenhomeDeviceT, _P, _R] + func: _FuncType[_OpenhomeDeviceT, _P, _R], ) -> _ReturnFuncType[_OpenhomeDeviceT, _P, _R]: """Call wrapper for decorator.""" diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 067282108f1..85e97209a44 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -49,7 +49,7 @@ INSECURE_PASSPHRASES = ( def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] + func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: """Handle OTBR errors.""" diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index fcd94ae5bcd..e2555308e34 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -95,7 +95,7 @@ MAP_CORE_ACTIVE_ZONES: dict[str, str] = { def _state_stateful_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if state := cast(str, select_state(OverkizState.CORE_ACTIVE_ZONES)): @@ -118,7 +118,7 @@ MAP_MYFOX_STATUS_STATE: dict[str, str] = { def _state_myfox_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if ( @@ -141,7 +141,7 @@ MAP_ARM_TYPE: dict[str, str] = { def _state_alarm_panel_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" return MAP_ARM_TYPE[ diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 3e6875f98b9..3e817b4ea1a 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -53,7 +53,7 @@ _LOGGER = logging.getLogger(__name__) def needs_session( - func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R] + func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R], ) -> Callable[Concatenate[_PlexMediaPlayerT, _P], _R | None]: """Ensure session is available for certain attributes.""" diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index 2abb1051d74..4f8d4c8d8fe 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -14,7 +14,7 @@ _P = ParamSpec("_P") def plugwise_command( - func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_PlugwiseEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate Plugwise calls that send commands/make changes to the device. diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index fde9b945e53..411691ca9f5 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -335,7 +335,7 @@ async def async_setup_entry( # noqa: C901 """Hydrate a service call with the appropriate controller.""" def decorator( - func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]] + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]], ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 361f8b2583b..0150f4cb600 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -117,7 +117,7 @@ _P = ParamSpec("_P") def raise_on_request_error( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a decorator to raise on a request error.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 78c475753a2..ad6cdd31e2c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -782,7 +782,7 @@ def _statistic_by_id_from_metadata( def _flatten_list_statistic_ids_metadata_result( - result: dict[str, dict[str, Any]] + result: dict[str, dict[str, Any]], ) -> list[dict]: """Return a flat dict of metadata.""" return [ diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 2d518d8874b..4a1bf940b24 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -658,7 +658,7 @@ def database_job_retry_wrapper( """ def decorator( - job: _WrappedFuncType[_RecorderT, _P] + job: _WrappedFuncType[_RecorderT, _P], ) -> _WrappedFuncType[_RecorderT, _P]: @functools.wraps(job) def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 6dd0dc2611e..e44a50d57a1 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -26,7 +26,7 @@ _P = ParamSpec("_P") def with_error_wrapping( - func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]] + func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]], ) -> Callable[Concatenate[RenaultVehicleProxy, _P], Coroutine[Any, Any, _T]]: """Catch Renault errors.""" diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 3ce45e5610e..cfacc627744 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -147,7 +147,7 @@ def _create_rfx(config: Mapping[str, Any]) -> rfxtrxmod.Connect: def _get_device_lookup( - devices: dict[str, dict[str, Any]] + devices: dict[str, dict[str, Any]], ) -> dict[DeviceTuple, dict[str, Any]]: """Get a lookup structure for devices.""" lookup = {} @@ -440,7 +440,7 @@ def get_device_id( def get_device_tuple_from_identifiers( - identifiers: set[tuple[str, str]] + identifiers: set[tuple[str, str]], ) -> DeviceTuple | None: """Calculate the device tuple from a device entry.""" identifier = next((x for x in identifiers if x[0] == DOMAIN and len(x) == 4), None) diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index 60392d89f1d..60a3cbeec30 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -32,7 +32,7 @@ def roku_exception_handler( """Decorate Roku calls to handle Roku exceptions.""" def decorator( - func: _FuncType[_RokuEntityT, _P] + func: _FuncType[_RokuEntityT, _P], ) -> _ReturnFuncType[_RokuEntityT, _P]: @wraps(func) async def wrapper( diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index b1d118e6f75..7d0437da033 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -191,7 +191,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def extract_api( - func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]] + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]], ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index f9056fa6624..5a755a7730c 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -19,7 +19,7 @@ _P = ParamSpec("_P") def async_handle_api_call( - function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]] + function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 80f7d6d51e4..56c5335e908 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -30,7 +30,7 @@ _P = ParamSpec("_P") def with_error_wrapping( - func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]] + func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]], ) -> Callable[Concatenate[SFRBoxButton, _P], Coroutine[Any, Any, _T]]: """Catch SFR errors.""" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b1bd2c8e9d6..772b6f9cbf6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -337,7 +337,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def extract_system( - func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]] + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 96ffeb1df2a..21e440673d6 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -112,7 +112,7 @@ async def async_generate_speaker_info( payload: dict[str, Any] = {} def get_contents( - item: int | float | str | dict[str, Any] + item: int | float | str | dict[str, Any], ) -> int | float | str | dict[str, Any]: if isinstance(item, (int, float, str)): return item diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 36eb37b7882..ef2fc3dc128 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -84,7 +84,7 @@ def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: def _ordered_shared_schema( - schema_input: dict[str, Any] + schema_input: dict[str, Any], ) -> dict[vol.Required | vol.Optional, Any]: return { vol.Required(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index afb341b47ed..2df9a856083 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -18,7 +18,7 @@ _P = ParamSpec("_P") def async_refresh_after( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a wrapper to refresh after.""" diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 416eb175d31..abb35df62aa 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -19,7 +19,7 @@ from .coordinator import TradfriDeviceDataUpdateCoordinator def handle_error( - func: Callable[[Command | list[Command]], Any] + func: Callable[[Command | list[Command]], Any], ) -> Callable[[Command | list[Command]], Coroutine[Any, Any, None]]: """Handle tradfri api call error.""" diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index f6015abd1f8..5b991fa35fb 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -48,7 +48,7 @@ def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]: def _build_channels_diagnostics_info( - channels: dict[str, VelbusChannel] + channels: dict[str, VelbusChannel], ) -> dict[str, Any]: """Build diagnostics info for all channels.""" data: dict[str, Any] = {} diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 45220e1a9b4..1a99f796eb2 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -48,7 +48,7 @@ _P = ParamSpec("_P") def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index c300f599faa..00b45e00b11 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -44,7 +44,7 @@ def new_options(lights: list[int], exclude: list[int]) -> dict[str, list[int]]: def options_schema( - options: Mapping[str, Any] | None = None + options: Mapping[str, Any] | None = None, ) -> dict[vol.Optional, type[str]]: """Return options schema.""" options = options or {} diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index ef1df676a2d..b84676776f5 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -45,7 +45,7 @@ async def async_setup_entry( def catch_vlc_errors( - func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]] + func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], ) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: """Catch VLC errors.""" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index b3c5a9b4910..96d66bb4395 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -68,7 +68,7 @@ _P = ParamSpec("_P") def _require_authentication( - func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any] + func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], ) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: """Authenticate with decorator using Wallbox API.""" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 61bef8c693c..f12b1c08c60 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -84,7 +84,7 @@ _P = ParamSpec("_P") def cmd( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cb90b46e182..dfd04aa001a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -650,7 +650,7 @@ async def handle_render_template( def _serialize_entity_sources( - entity_infos: dict[str, entity.EntityInfo] + entity_infos: dict[str, entity.EntityInfo], ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" return { diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 85dcf9ca800..b4b5ee4c892 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -15,7 +15,7 @@ _P = ParamSpec("_P") def wled_exception_handler( - func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, None]]: """Decorate WLED calls to handle WLED exceptions. diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 5cf068e2d70..a20924b268a 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -248,7 +248,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration] + homekit_models: dict[str, HomeKitDiscoveredIntegration], ) -> tuple[ dict[str, HomeKitDiscoveredIntegration], dict[re.Pattern, HomeKitDiscoveredIntegration], diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 65c77f8ab2d..a211832039b 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -457,7 +457,7 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: def check_type_schema_map( - schema_map: dict[str, vol.Schema] + schema_map: dict[str, vol.Schema], ) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 12c1ed242af..9b4f9827c1d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -49,7 +49,7 @@ T = TypeVar("T", ZwaveNode, Endpoint) def parameter_name_does_not_need_bitmask( - val: dict[str, int | str | list[str]] + val: dict[str, int | str | list[str]], ) -> dict[str, int | str | list[str]]: """Validate that if a parameter name is provided, bitmask is not as well.""" if ( diff --git a/homeassistant/config.py b/homeassistant/config.py index 95dd42737a0..d5b6864c937 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -155,7 +155,7 @@ class IntegrationConfigInfo: def _no_duplicate_auth_provider( - configs: Sequence[dict[str, Any]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth provider config allowed in a list. @@ -176,7 +176,7 @@ def _no_duplicate_auth_provider( def _no_duplicate_auth_mfa_module( - configs: Sequence[dict[str, Any]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth mfa module item allowed in a list. diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 1a449ec15f0..dd61357f53e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -93,7 +93,7 @@ FILTER_SCHEMA = vol.All(BASE_FILTER_SCHEMA, convert_filter) def convert_include_exclude_filter( - config: dict[str, dict[str, list[str]]] + config: dict[str, dict[str, list[str]]], ) -> EntityFilter: """Convert the include exclude filter schema into a filter.""" include = config[CONF_INCLUDE] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 1de7a6c6a43..02add8ff012 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -136,7 +136,7 @@ class EventStateChangedData(TypedDict): def threaded_listener_factory( - async_factory: Callable[Concatenate[HomeAssistant, _P], Any] + async_factory: Callable[Concatenate[HomeAssistant, _P], Any], ) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: """Convert an async event helper to a threaded one.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 2ada25bd4cd..b7a81c3fb19 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -998,7 +998,7 @@ def verify_domain_control( """Ensure permission to access any entity under domain in service call.""" def decorator( - service_handler: Callable[[ServiceCall], Any] + service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" if not asyncio.iscoroutinefunction(service_handler): diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index d6a31085cfb..eac5cdb0a3f 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -67,7 +67,7 @@ def component_translation_path( def load_translations_files( - translation_files: dict[str, str] + translation_files: dict[str, str], ) -> dict[str, dict[str, Any]]: """Load and parse translation.json files.""" loaded = {} diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 300b9ced616..0f86cde50fe 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -189,7 +189,7 @@ def catch_log_coro_exception( def async_create_catching_coro( - target: Coroutine[Any, Any, _T] + target: Coroutine[Any, Any, _T], ) -> Coroutine[Any, Any, _T | None]: """Wrap a coroutine to catch and log exceptions. diff --git a/pyproject.toml b/pyproject.toml index 304d2844cad..f92baa71288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -582,7 +582,6 @@ select = [ "G", # flake8-logging-format "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} - "ISC001", # Implicitly concatenated string literals on one line "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase @@ -658,6 +657,21 @@ ignore = [ # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c797db4b7a3..a02eed66ffa 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.2.2 -ruff==0.1.6 +ruff==0.1.8 yamllint==1.32.0 diff --git a/tests/common.py b/tests/common.py index c402f2aa661..b07788dc3d7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1225,7 +1225,7 @@ class MockEntity(entity.Entity): @contextmanager def mock_storage( - data: dict[str, Any] | None = None + data: dict[str, Any] | None = None, ) -> Generator[dict[str, Any], None, None]: """Mock storage. diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 2af5e67f845..55e4d8d5c65 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -2285,7 +2285,6 @@ async def test_cast_platform_play_media_local_media( quick_play_mock.assert_called() app_data = quick_play_mock.call_args[0][2] # No authSig appended - assert ( - app_data["media_id"] - == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" - ) + assert app_data[ + "media_id" + ] == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index d31eb70a0b4..3b37902fb3d 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -320,7 +320,7 @@ async def mock_bluetooth_entry( """Set up an ESPHome entry with bluetooth.""" async def _mock_bluetooth_entry( - bluetooth_proxy_feature_flags: BluetoothProxyFeature + bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: return await _mock_generic_device_entry( hass, @@ -348,7 +348,7 @@ async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHome @pytest.fixture async def mock_bluetooth_entry_with_legacy_adv( - mock_bluetooth_entry + mock_bluetooth_entry, ) -> MockESPHomeDevice: """Set up an ESPHome entry with bluetooth with legacy advertisements.""" return await mock_bluetooth_entry( diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index c64972b7904..1255ba79388 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -37,7 +37,7 @@ async def setup_fronius_integration( def _load_and_patch_fixture( - override_data: dict[str, list[tuple[list[str], Any]]] + override_data: dict[str, list[tuple[list[str], Any]]], ) -> Callable[[str, str | None], str]: """Return a fixture loader that patches values at nested keys for a given filename.""" diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 3b2ed6d24e1..4196604b5d4 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -260,7 +260,7 @@ def mock_events_list( @pytest.fixture def mock_events_list_items( - mock_events_list: Callable[[dict[str, Any]], None] + mock_events_list: Callable[[dict[str, Any]], None], ) -> Callable[[list[dict[str, Any]]], None]: """Fixture to construct an API response containing event items.""" diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index c3882553ea0..e4fe754013a 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -277,7 +277,7 @@ async def test_climate_change_thermostat_temperature_range(hass: HomeAssistant) async def test_climate_change_thermostat_temperature_range_iphone( - hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test that we can set all three set points at once (iPhone heat_cool mode support).""" helper = await setup_test_component(hass, create_thermostat_service) @@ -312,7 +312,7 @@ async def test_climate_change_thermostat_temperature_range_iphone( async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode( - hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test that we cannot set range values when not in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -371,7 +371,7 @@ def create_thermostat_single_set_point_auto(accessory): async def test_climate_check_min_max_values_per_mode_sspa_device( - hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test appropriate min/max values for each mode on sspa devices.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 7afadadcd98..938f09c453e 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -749,7 +749,7 @@ async def test_v2_oscillate_read(hass: HomeAssistant) -> None: async def test_v2_set_percentage_non_standard_rotation_range( - hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test that we set fan speed with a non-standard rotation range.""" helper = await setup_test_component( diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 1f892785812..8e22fbe84f7 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -116,7 +116,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_added_config_entry( - mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]] + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], ) -> MockConfigEntry: """Mock ConfigEntry that's been added to HA.""" return await mock_add_config_entry() diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index f0c77c9bce3..e333071b0bd 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -302,7 +302,7 @@ async def _test_common_success_w_authorize( """Test bluetooth and user flow success paths.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None @@ -612,7 +612,7 @@ async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None """Test bluetooth flow with error.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 1ca6413fc42..5bb84c7a1c1 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -58,7 +58,7 @@ async def init_integration( @pytest.fixture async def get_items( - hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 922ec7b0a5a..44baf09fd55 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -115,7 +115,7 @@ def mock_insert_schedule_response( @pytest.fixture(name="get_events") def get_events_fixture( - hass_client: Callable[..., Awaitable[ClientSession]] + hass_client: Callable[..., Awaitable[ClientSession]], ) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 21016a65cc2..21af6b01182 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -118,7 +118,7 @@ def _add_db_entries( def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -246,7 +246,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -410,7 +410,7 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -455,7 +455,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -554,7 +554,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -588,7 +588,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -604,7 +604,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -626,7 +626,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -644,7 +644,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -1082,7 +1082,7 @@ async def test_get_full_significant_states_handles_empty_last_changed( def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -1141,7 +1141,7 @@ async def test_get_full_significant_states_past_year_2038( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -1151,7 +1151,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -1161,7 +1161,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -1173,7 +1173,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1182,7 +1182,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1193,7 +1193,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 0ed6061de98..4f75dc15b15 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -37,7 +37,7 @@ def db_schema_30(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -240,7 +240,7 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -293,7 +293,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -362,7 +362,7 @@ def test_get_significant_states_minimal_response( def test_get_significant_states_with_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -399,7 +399,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -435,7 +435,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -453,7 +453,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -480,7 +480,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -501,7 +501,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -644,7 +644,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -669,7 +669,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -689,7 +689,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -701,7 +701,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -710,7 +710,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -721,7 +721,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 5b721cd4c87..477c13d6166 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -37,7 +37,7 @@ def db_schema_32(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -239,7 +239,7 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -292,7 +292,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -389,7 +389,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -425,7 +425,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -443,7 +443,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -470,7 +470,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -491,7 +491,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -634,7 +634,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -659,7 +659,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -669,7 +669,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -691,7 +691,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -700,7 +700,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -711,7 +711,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 0dfbb6005c4..a9a12d72c41 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -537,7 +537,7 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_saving_state_with_commit_interval_zero( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving a state with a commit interval of zero.""" hass = hass_recorder({"commit_interval": 0}) @@ -594,7 +594,7 @@ def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> def test_saving_state_include_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"domains": "test2"}}) @@ -604,7 +604,7 @@ def test_saving_state_include_domains( def test_saving_state_include_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -627,7 +627,7 @@ def test_saving_state_include_domains_globs( def test_saving_state_incl_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"entities": "test2.recorder"}}) @@ -688,7 +688,7 @@ async def test_saving_event_exclude_event_type( def test_saving_state_exclude_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"domains": "test"}}) @@ -698,7 +698,7 @@ def test_saving_state_exclude_domains( def test_saving_state_exclude_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -712,7 +712,7 @@ def test_saving_state_exclude_domains_globs( def test_saving_state_exclude_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"entities": "test.recorder"}}) @@ -722,7 +722,7 @@ def test_saving_state_exclude_entities( def test_saving_state_exclude_domain_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -733,7 +733,7 @@ def test_saving_state_exclude_domain_include_entity( def test_saving_state_exclude_domain_glob_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -749,7 +749,7 @@ def test_saving_state_exclude_domain_glob_include_entity( def test_saving_state_include_domain_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -762,7 +762,7 @@ def test_saving_state_include_domain_exclude_entity( def test_saving_state_include_domain_glob_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -780,7 +780,7 @@ def test_saving_state_include_domain_glob_exclude_entity( def test_saving_state_and_removing_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving the state of a removed entity.""" hass = hass_recorder() @@ -1025,7 +1025,7 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does a repack on the 2nd sunday.""" hass = hass_recorder() @@ -1065,7 +1065,7 @@ def test_auto_purge_auto_repack_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_disabled_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled.""" hass = hass_recorder({CONF_AUTO_REPACK: False}) @@ -1105,7 +1105,7 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_no_auto_repack_on_not_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not do a repack unless its the 2nd sunday.""" hass = hass_recorder() @@ -1431,7 +1431,7 @@ def test_has_services(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_service_disable_events_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that events are not recorded when recorder is disabled using service.""" hass = hass_recorder() @@ -1515,7 +1515,7 @@ def test_service_disable_events_not_recording( def test_service_disable_states_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that state changes are not recorded when recorder is disabled using service.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 03dc7b84caa..69b7f9316f7 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -432,7 +432,7 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_statistics_during_period_set_back_compat( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test statistics_during_period can handle a list instead of a set.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 0a30895adc9..66daced2ca8 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -883,7 +883,7 @@ def test_build_mysqldb_conv() -> None: @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) def test_execute_stmt_lambda_element( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test executing with execute_stmt_lambda_element.""" hass = hass_recorder() diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 874697bf777..6754faf2da6 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -233,7 +233,7 @@ def remotews_fixture() -> Mock: remotews.app_list_data = None async def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remotews.ws_event_callback = ws_event_callback @@ -272,7 +272,7 @@ def remoteencws_fixture() -> Mock: remoteencws.__aexit__ = AsyncMock() def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remoteencws.ws_event_callback = ws_event_callback diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 24162296cd5..e3d5741acd8 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -731,7 +731,7 @@ async def test_zll_device_groups( mock.MagicMock(), ) async def test_cluster_no_ep_attribute( - zha_device_mock: Callable[..., ZHADevice] + zha_device_mock: Callable[..., ZHADevice], ) -> None: """Test cluster handlers for clusters without ep_attribute.""" diff --git a/tests/conftest.py b/tests/conftest.py index 696a5a2ed15..ea4ddd23d28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -973,7 +973,7 @@ async def _mqtt_mock_entry( mock_mqtt_instance = None async def _setup_mqtt_entry( - setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]] + setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]], ) -> MagicMock: """Set up the MQTT config entry.""" assert await setup_entry(hass, entry) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 6816c7701aa..cb90d8e2bed 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -325,7 +325,7 @@ def test_check_if_deprecated_constant( def test_test_check_if_deprecated_constant_invalid( - caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: """Test check_if_deprecated_constant will raise an attribute error and create an log entry on an invalid deprecation type.""" module_name = "homeassistant.components.hue.light" From dfb08e7efd8c5c1fc77ecbaa447440802d4f881b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 12:57:20 -1000 Subject: [PATCH 560/927] Remove unneeded usage of run_callback_threadsafe in entity helper (#106138) We do not need to create a future here as we do not need to wait for the result as its going to run in the background in a task anyways --- homeassistant/helpers/entity_platform.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index be087241287..221203902c5 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -31,7 +31,6 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.generated import languages from homeassistant.setup import async_start_setup -from homeassistant.util.async_ import run_callback_threadsafe from . import ( config_validation as cv, @@ -429,12 +428,11 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform, synchronously.""" - run_callback_threadsafe( - self.hass.loop, + self.hass.loop.call_soon_threadsafe( self._async_schedule_add_entities, list(new_entities), update_before_add, - ).result() + ) @callback def _async_schedule_add_entities( From 494a8975687e67f7b9889db1ccfffd6923d5f17c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 21 Dec 2023 00:00:21 +0100 Subject: [PATCH 561/927] Deprecate deprecated climate constants (#106096) --- homeassistant/components/climate/__init__.py | 35 ++++++---- homeassistant/components/climate/const.py | 65 +++++++++++++------ .../components/climate/device_action.py | 2 +- .../components/climate/device_condition.py | 2 +- .../components/climate/test_device_action.py | 4 +- tests/components/climate/test_init.py | 64 +++++++++++++++++- .../specific_devices/test_ecobee3.py | 12 ++-- tests/components/matter/test_climate.py | 31 +++------ 8 files changed, 150 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3e93bf27ffc..5047714e097 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -26,6 +26,10 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -33,6 +37,20 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 + _DEPRECATED_HVAC_MODE_AUTO, + _DEPRECATED_HVAC_MODE_COOL, + _DEPRECATED_HVAC_MODE_DRY, + _DEPRECATED_HVAC_MODE_FAN_ONLY, + _DEPRECATED_HVAC_MODE_HEAT, + _DEPRECATED_HVAC_MODE_HEAT_COOL, + _DEPRECATED_HVAC_MODE_OFF, + _DEPRECATED_SUPPORT_AUX_HEAT, + _DEPRECATED_SUPPORT_FAN_MODE, + _DEPRECATED_SUPPORT_PRESET_MODE, + _DEPRECATED_SUPPORT_SWING_MODE, + _DEPRECATED_SUPPORT_TARGET_HUMIDITY, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE, ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, @@ -64,10 +82,6 @@ from .const import ( # noqa: F401 FAN_OFF, FAN_ON, FAN_TOP, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, HVAC_MODES, PRESET_ACTIVITY, PRESET_AWAY, @@ -84,13 +98,6 @@ from .const import ( # noqa: F401 SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SUPPORT_AUX_HEAT, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_SWING_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -128,6 +135,12 @@ SET_TEMPERATURE_SCHEMA = vol.All( ), ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 23c76c151d7..615dc7d48dd 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,6 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) class HVACMode(StrEnum): @@ -31,13 +38,13 @@ class HVACMode(StrEnum): # These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACMode enum instead. -HVAC_MODE_OFF = "off" -HVAC_MODE_HEAT = "heat" -HVAC_MODE_COOL = "cool" -HVAC_MODE_HEAT_COOL = "heat_cool" -HVAC_MODE_AUTO = "auto" -HVAC_MODE_DRY = "dry" -HVAC_MODE_FAN_ONLY = "fan_only" +_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1") +_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1") +_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1") +_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1") +_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1") HVAC_MODES = [cls.value for cls in HVACMode] # No preset is active @@ -99,12 +106,12 @@ class HVACAction(StrEnum): # These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACAction enum instead. -CURRENT_HVAC_OFF = "off" -CURRENT_HVAC_HEAT = "heating" -CURRENT_HVAC_COOL = "cooling" -CURRENT_HVAC_DRY = "drying" -CURRENT_HVAC_IDLE = "idle" -CURRENT_HVAC_FAN = "fan" +_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1") +_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1") +_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1") +_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1") +_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1") +_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1") CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] @@ -159,10 +166,28 @@ class ClimateEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the ClimateEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_TARGET_TEMPERATURE_RANGE = 2 -SUPPORT_TARGET_HUMIDITY = 4 -SUPPORT_FAN_MODE = 8 -SUPPORT_PRESET_MODE = 16 -SUPPORT_SWING_MODE = 32 -SUPPORT_AUX_HEAT = 64 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_HUMIDITY, "2025.1" +) +_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.FAN_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.PRESET_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.SWING_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum( + ClimateEntityFeature.AUX_HEAT, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 6714e0bf35a..a920884c252 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -72,7 +72,7 @@ async def async_get_actions( } actions.append({**base_action, CONF_TYPE: "set_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: actions.append({**base_action, CONF_TYPE: "set_preset_mode"}) return actions diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 57b9654651b..78f358db32e 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -71,7 +71,7 @@ async def async_get_conditions( conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: conditions.append({**base_condition, CONF_TYPE: "is_preset_mode"}) return conditions diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 8ef73ed4e51..1fc379487ed 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -220,7 +220,7 @@ async def test_action( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } assert set_preset_mode_calls[0].domain == DOMAIN assert set_preset_mode_calls[0].service == "set_preset_mode" @@ -287,7 +287,7 @@ async def test_action_legacy( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 897a7316c95..1181a432ea2 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1,11 +1,14 @@ """The tests for the climate component.""" from __future__ import annotations +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock import pytest import voluptuous as vol +from homeassistant.components import climate from homeassistant.components.climate import ( SET_TEMPERATURE_SCHEMA, ClimateEntity, @@ -13,7 +16,11 @@ from homeassistant.components.climate import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import ( + async_mock_service, + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, +) async def test_set_temp_schema_no_req( @@ -96,3 +103,58 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await climate.async_turn_off() assert climate.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(climate.ClimateEntityFeature, "SUPPORT_") + + _create_tuples(climate.HVACMode, "HVAC_MODE_"), +) +@pytest.mark.parametrize( + "module", + [climate, climate.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) + + +@pytest.mark.parametrize( + ("enum", "constant_postfix"), + [ + (climate.HVACAction.OFF, "OFF"), + (climate.HVACAction.HEATING, "HEAT"), + (climate.HVACAction.COOLING, "COOL"), + (climate.HVACAction.DRYING, "DRY"), + (climate.HVACAction.IDLE, "IDLE"), + (climate.HVACAction.FAN, "FAN"), + ], +) +def test_deprecated_current_constants( + caplog: pytest.LogCaptureFixture, + enum: climate.HVACAction, + constant_postfix: str, +) -> None: + """Test deprecated current constants.""" + import_and_test_deprecated_constant( + caplog, + climate.const, + "CURRENT_HVAC_" + constant_postfix, + f"{enum.__class__.__name__}.{enum.name}", + enum, + "2025.1", + ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 62051bbf244..723881ac182 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -8,11 +8,7 @@ from unittest import mock from aiohomekit import AccessoryNotFoundError from aiohomekit.testing import FakePairing -from homeassistant.components.climate import ( - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import UnitOfTemperature @@ -108,9 +104,9 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: friendly_name="HomeW", unique_id="00:00:00:00:00:00_1_16", supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY ), capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index ec8453b5c56..81d210ed579 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -6,18 +6,7 @@ from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from homeassistant.components.climate import ( - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVACAction, - HVACMode, -) -from homeassistant.components.climate.const import ( - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_OFF, -) +from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import HomeAssistant from .common import ( @@ -51,7 +40,7 @@ async def test_thermostat( # test set temperature when target temp is None assert state.attributes["temperature"] is None - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL with pytest.raises( ValueError, match="Current target_temperature should not be None" ): @@ -85,7 +74,7 @@ async def test_thermostat( ): state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL await hass.services.async_call( "climate", "set_temperature", @@ -119,19 +108,19 @@ async def test_thermostat( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_OFF + assert state.state == HVACMode.OFF set_node_attribute(thermostat, 1, 513, 28, 7) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_FAN_ONLY + assert state.state == HVACMode.FAN_ONLY set_node_attribute(thermostat, 1, 513, 28, 8) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_DRY + assert state.state == HVACMode.DRY # test running state update from device set_node_attribute(thermostat, 1, 513, 41, 1) @@ -188,7 +177,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT + assert state.state == HVACMode.HEAT # change occupied heating setpoint to 20 set_node_attribute(thermostat, 1, 513, 18, 2000) @@ -225,7 +214,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 1800) @@ -273,7 +262,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 2500) @@ -340,7 +329,7 @@ async def test_thermostat( "set_hvac_mode", { "entity_id": "climate.longan_link_hvac", - "hvac_mode": HVAC_MODE_HEAT, + "hvac_mode": HVACMode.HEAT, }, blocking=True, ) From d47ec9123153c6a8cc5d3644956b82e82bca8458 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 21 Dec 2023 00:02:20 +0100 Subject: [PATCH 562/927] Deprecate deprecated sensor constants (#106120) --- homeassistant/components/sensor/__init__.py | 17 +++++++++++++--- homeassistant/components/sensor/const.py | 22 ++++++++++++++++++--- tests/components/sensor/test_init.py | 16 +++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9cdcfade9ec..993deae280a 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -7,6 +7,7 @@ from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation +from functools import partial import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final @@ -57,6 +58,10 @@ from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -66,6 +71,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from .const import ( # noqa: F401 + _DEPRECATED_STATE_CLASS_MEASUREMENT, + _DEPRECATED_STATE_CLASS_TOTAL, + _DEPRECATED_STATE_CLASS_TOTAL_INCREASING, ATTR_LAST_RESET, ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -76,9 +84,6 @@ from .const import ( # noqa: F401 DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -110,6 +115,12 @@ __all__ = [ "SensorStateClass", ] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e8b1742f315..d57a09981ef 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, @@ -451,11 +457,21 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) # STATE_CLASS* is deprecated as of 2021.12 # use the SensorStateClass enum instead. -STATE_CLASS_MEASUREMENT: Final = "measurement" -STATE_CLASS_TOTAL: Final = "total" -STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" +_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum( + SensorStateClass.MEASUREMENT, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL_INCREASING, "2025.1" +) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 9164bb442c3..2940d76f0a6 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,10 +4,12 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +from types import ModuleType from typing import Any import pytest +from homeassistant.components import sensor from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, @@ -50,6 +52,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -2519,3 +2522,16 @@ async def test_entity_category_config_raises_error( ) assert not hass.states.get("sensor.test") + + +@pytest.mark.parametrize(("enum"), list(sensor.SensorStateClass)) +@pytest.mark.parametrize(("module"), [sensor, sensor.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STATE_CLASS_", "2025.1" + ) From 5722b4a1ce34b91f3c7ea293d3e3bb66b33cbefa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 13:36:37 -1000 Subject: [PATCH 563/927] Break out the ESPHome Bluetooth scanner connection logic into bleak-esphome (#105908) --- homeassistant/components/esphome/bluetooth.py | 44 ++++++ .../components/esphome/bluetooth/__init__.py | 129 ------------------ .../test_init.py => test_bluetooth.py} | 2 +- 3 files changed, 45 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/esphome/bluetooth.py delete mode 100644 homeassistant/components/esphome/bluetooth/__init__.py rename tests/components/esphome/{bluetooth/test_init.py => test_bluetooth.py} (97%) diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py new file mode 100644 index 00000000000..9534074b678 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth.py @@ -0,0 +1,44 @@ +"""Bluetooth support for esphome.""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from aioesphomeapi import APIClient, DeviceInfo +from bleak_esphome import connect_scanner +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + +from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback + +from .entry_data import RuntimeEntryData + + +@hass_callback +def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: + """Cancel all the callbacks on unload.""" + for callback in unload_callbacks: + callback() + + +async def async_connect_scanner( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + cli: APIClient, + device_info: DeviceInfo, + cache: ESPHomeBluetoothCache, +) -> CALLBACK_TYPE: + """Connect scanner.""" + client_data = await connect_scanner(cli, device_info, cache, entry_data.available) + entry_data.bluetooth_device = client_data.bluetooth_device + client_data.disconnect_callbacks = entry_data.disconnect_callbacks + scanner = client_data.scanner + if TYPE_CHECKING: + assert scanner is not None + return partial( + _async_unload, + [ + async_register_scanner(hass, scanner, scanner.connectable), + scanner.async_setup(), + ], + ) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py deleted file mode 100644 index 88f47fe601d..00000000000 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Bluetooth support for esphome.""" -from __future__ import annotations - -import asyncio -from collections.abc import Coroutine -from functools import partial -import logging -from typing import TYPE_CHECKING, Any - -from aioesphomeapi import APIClient, BluetoothProxyFeature, DeviceInfo -from bleak_esphome.backend.cache import ESPHomeBluetoothCache -from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData -from bleak_esphome.backend.device import ESPHomeBluetoothDevice -from bleak_esphome.backend.scanner import ESPHomeScanner - -from homeassistant.components.bluetooth import ( - HaBluetoothConnector, - async_register_scanner, -) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback - -from ..entry_data import RuntimeEntryData - -_LOGGER = logging.getLogger(__name__) - - -def _async_can_connect(bluetooth_device: ESPHomeBluetoothDevice, source: str) -> bool: - """Check if a given source can make another connection.""" - can_connect = bool( - bluetooth_device.available and bluetooth_device.ble_connections_free - ) - _LOGGER.debug( - ( - "%s [%s]: Checking can connect, available=%s, ble_connections_free=%s" - " result=%s" - ), - bluetooth_device.name, - source, - bluetooth_device.available, - bluetooth_device.ble_connections_free, - can_connect, - ) - return can_connect - - -@hass_callback -def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: - """Cancel all the callbacks on unload.""" - for callback in unload_callbacks: - callback() - - -async def async_connect_scanner( - hass: HomeAssistant, - entry_data: RuntimeEntryData, - cli: APIClient, - device_info: DeviceInfo, - cache: ESPHomeBluetoothCache, -) -> CALLBACK_TYPE: - """Connect scanner.""" - source = device_info.mac_address - name = device_info.name - if TYPE_CHECKING: - assert cli.api_version is not None - feature_flags = device_info.bluetooth_proxy_feature_flags_compat(cli.api_version) - connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) - bluetooth_device = ESPHomeBluetoothDevice( - name, device_info.mac_address, available=entry_data.available - ) - entry_data.bluetooth_device = bluetooth_device - _LOGGER.debug( - "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", - name, - source, - feature_flags, - connectable, - ) - client_data = ESPHomeClientData( - bluetooth_device=bluetooth_device, - cache=cache, - client=cli, - device_info=device_info, - api_version=cli.api_version, - title=name, - scanner=None, - disconnect_callbacks=entry_data.disconnect_callbacks, - ) - connector = HaBluetoothConnector( - # MyPy doesn't like partials, but this is correct - # https://github.com/python/mypy/issues/1484 - client=partial(ESPHomeClient, client_data=client_data), # type: ignore[arg-type] - source=source, - can_connect=partial(_async_can_connect, bluetooth_device, source), - ) - scanner = ESPHomeScanner(source, name, connector, connectable) - client_data.scanner = scanner - coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] - # These calls all return a callback that can be used to unsubscribe - # but we never unsubscribe so we don't care about the return value - - if connectable: - # If its connectable be sure not to register the scanner - # until we know the connection is fully setup since otherwise - # there is a race condition where the connection can fail - coros.append( - cli.subscribe_bluetooth_connections_free( - bluetooth_device.async_update_ble_connection_limits - ) - ) - - if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: - coros.append( - cli.subscribe_bluetooth_le_raw_advertisements( - scanner.async_on_raw_advertisements - ) - ) - else: - coros.append( - cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - ) - - await asyncio.gather(*coros) - return partial( - _async_unload, - [ - async_register_scanner(hass, scanner, connectable), - scanner.async_setup(), - ], - ) diff --git a/tests/components/esphome/bluetooth/test_init.py b/tests/components/esphome/test_bluetooth.py similarity index 97% rename from tests/components/esphome/bluetooth/test_init.py rename to tests/components/esphome/test_bluetooth.py index d9d6f1947c9..a576c82c944 100644 --- a/tests/components/esphome/bluetooth/test_init.py +++ b/tests/components/esphome/test_bluetooth.py @@ -3,7 +3,7 @@ from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from ..conftest import MockESPHomeDevice +from .conftest import MockESPHomeDevice async def test_bluetooth_connect_with_raw_adv( From ced4123d4c2eb31e64fa55f52a7ee87abc7fd9ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 14:10:56 -1000 Subject: [PATCH 564/927] Bump pyenphase to 1.15.2 (#106134) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index c49e1f143e6..4ae7760a56b 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.3"], + "requirements": ["pyenphase==1.15.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 0a13f0f109b..df39934a6df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1733,7 +1733,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.3 +pyenphase==1.15.2 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbf46da4cbf..4fa863691d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.3 +pyenphase==1.15.2 # homeassistant.components.everlights pyeverlights==0.1.0 From e2314565bbb8edba6e02228a5cb7c2c4b0bc52f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 18:33:43 -1000 Subject: [PATCH 565/927] Fix ESPHome Bluetooth diagnostics (#106151) --- .../components/esphome/diagnostics.py | 3 +- tests/components/esphome/conftest.py | 6 +- tests/components/esphome/test_bluetooth.py | 12 +-- tests/components/esphome/test_config_flow.py | 2 +- tests/components/esphome/test_diagnostics.py | 79 +++++++++++++++++++ tests/components/esphome/test_entry_data.py | 6 +- tests/components/esphome/test_manager.py | 11 ++- tests/components/esphome/test_sensor.py | 4 +- 8 files changed, 105 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index a984d057c0c..f270196db50 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -32,12 +32,13 @@ async def async_get_config_entry_diagnostics( if ( config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id)) + and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { "connections_free": bluetooth_device.ble_connections_free, "connections_limit": bluetooth_device.ble_connections_limit, + "available": bluetooth_device.available, "scanner": await scanner.async_diagnostics(), } diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 3b37902fb3d..9182e021a65 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -72,6 +72,7 @@ def mock_config_entry(hass) -> MockConfigEntry: CONF_NOISE_PSK: "12345678123456781234567812345678", CONF_DEVICE_NAME: "test", }, + # ESPHome unique ids are lower case unique_id="11:22:33:44:55:aa", ) config_entry.add_to_hass(hass) @@ -96,7 +97,8 @@ def mock_device_info() -> DeviceInfo: uses_password=False, name="test", legacy_bluetooth_proxy_version=0, - mac_address="11:22:33:44:55:aa", + # ESPHome mac addresses are UPPER case + mac_address="11:22:33:44:55:AA", esphome_version="1.0.0", ) @@ -230,7 +232,7 @@ async def _mock_generic_device_entry( "name": "test", "friendly_name": "Test", "esphome_version": "1.0.0", - "mac_address": "11:22:33:44:55:aa", + "mac_address": "11:22:33:44:55:AA", } device_info = DeviceInfo(**(default_device_info | mock_device_info)) diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index a576c82c944..46858c5826b 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -10,7 +10,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -18,11 +18,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner.scanning is True @@ -30,7 +30,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -38,9 +38,9 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:aa") + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner.scanning is True diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 01ba07852d6..4161e69efd0 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -432,7 +432,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( DeviceInfo( uses_password=False, name="test", - mac_address="11:22:33:44:55:aa", + mac_address="11:22:33:44:55:AA", ), ] diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 6000b270d87..d528010af1b 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,8 +1,13 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from unittest.mock import ANY + from syrupy import SnapshotAssertion +from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from .conftest import MockESPHomeDevice + from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -20,3 +25,77 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) assert result == snapshot + + +async def test_diagnostics_with_bluetooth( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, +) -> None: + """Test diagnostics for config entry with Bluetooth.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + entry = mock_bluetooth_entry_with_raw_adv.entry + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result == { + "bluetooth": { + "available": True, + "connections_free": 0, + "connections_limit": 0, + "scanner": { + "connectable": True, + "discovered_device_timestamps": {}, + "discovered_devices_and_advertisement_data": [], + "last_detection": ANY, + "monotonic_time": ANY, + "name": "test (11:22:33:44:55:AA)", + "scanning": True, + "source": "11:22:33:44:55:AA", + "start_time": ANY, + "time_since_last_device_detection": {}, + "type": "ESPHomeScanner", + }, + }, + "config": { + "data": { + "device_name": "test", + "host": "test.local", + "password": "", + "port": 6053, + }, + "disabled_by": None, + "domain": "esphome", + "entry_id": ANY, + "minor_version": 1, + "options": {"allow_service_calls": False}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": "11:22:33:44:55:aa", + "version": 1, + }, + "storage_data": { + "api_version": {"major": 99, "minor": 99}, + "device_info": { + "bluetooth_proxy_feature_flags": 63, + "compilation_time": "", + "esphome_version": "1.0.0", + "friendly_name": "Test", + "has_deep_sleep": False, + "legacy_bluetooth_proxy_version": 0, + "mac_address": "**REDACTED**", + "manufacturer": "", + "model": "", + "name": "test", + "project_name": "", + "project_version": "", + "suggested_area": "", + "uses_password": False, + "voice_assistant_version": 0, + "webserver_port": 0, + }, + "services": [], + }, + } diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 0ba43092d01..a8535c38224 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -51,7 +51,7 @@ async def test_migrate_entity_unique_id( assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" async def test_migrate_entity_unique_id_downgrade_upgrade( @@ -71,7 +71,7 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( entity_registry.async_get_or_create( "sensor", "esphome", - "11:22:33:44:55:aa-sensor-mysensor", + "11:22:33:44:55:AA-sensor-mysensor", suggested_object_id="new_sensor", disabled_by=None, ) @@ -108,4 +108,4 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( ) # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 244e7487ed3..69ed653d75b 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -45,7 +45,7 @@ async def test_esphome_device_with_old_bluetooth( await hass.async_block_till_done() issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( - "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) assert ( issue.learn_more_url @@ -87,7 +87,10 @@ async def test_esphome_device_with_password( issue_registry = ir.async_get(hass) assert ( issue_registry.async_get_issue( - "esphome", "api_password_deprecated-11:22:33:44:55:aa" + # This issue uses the ESPHome mac address which + # is always UPPER case + "esphome", + "api_password_deprecated-11:22:33:44:55:AA", ) is not None ) @@ -118,8 +121,10 @@ async def test_esphome_device_with_current_bluetooth( await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert ( + # This issue uses the ESPHome device info mac address which + # is always UPPER case issue_registry.async_get_issue( - "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) is None ) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 820ec9ad9c0..080976425f9 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -118,7 +118,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -156,7 +156,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is None From 7c5824b4f389ae11a97361caf1f084677eca6aec Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 21 Dec 2023 15:18:18 +1000 Subject: [PATCH 566/927] Add climate platform to Tessie (#105420) * Add climate platform * Other fixes * Use super native value * change to _value * Sentence case strings * Add some more type definition * Add return types * Add some more assertions * Remove VirtualKey error * Add type to args * rename climate to primary * fix min max * Use String Enum * Add PRECISION_HALVES * Fix string enum * fix str enum * Simplify run logic * Rename enum to TessieClimateKeeper --- homeassistant/components/tessie/__init__.py | 2 +- homeassistant/components/tessie/climate.py | 134 +++++++++++++++++++ homeassistant/components/tessie/const.py | 9 ++ homeassistant/components/tessie/entity.py | 29 +++- homeassistant/components/tessie/strings.json | 15 +++ tests/components/tessie/test_climate.py | 124 +++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tessie/climate.py create mode 100644 tests/components/tessie/test_climate.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a1553aa0c7e..fdffaffa538 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py new file mode 100644 index 00000000000..48fe73919cd --- /dev/null +++ b/homeassistant/components/tessie/climate.py @@ -0,0 +1,134 @@ +"""Climate platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + set_climate_keeper_mode, + set_temperature, + start_climate_preconditioning, + stop_climate, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieClimateKeeper +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Climate platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieClimateEntity(coordinator) for coordinator in coordinators) + + +class TessieClimateEntity(TessieEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes: list = [ + TessieClimateKeeper.OFF, + TessieClimateKeeper.ON, + TessieClimateKeeper.DOG, + TessieClimateKeeper.CAMP, + ] + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the Climate entity.""" + super().__init__(coordinator, "primary") + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get("climate_state_driver_temp_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.run(start_climate_preconditioning) + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.run(stop_climate) + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + await self.run(set_temperature, temperature=temp) + self.set(("climate_state_driver_temp_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + await self.run( + set_climate_keeper_mode, mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 3aa7dbb185d..b7dcaea4420 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -18,3 +18,12 @@ class TessieStatus(StrEnum): ASLEEP = "asleep" ONLINE = "online" + + +class TessieClimateKeeper(StrEnum): + """Tessie Climate Keeper Modes.""" + + OFF = "off" + ON = "on" + DOG = "dog" + CAMP = "camp" diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index a0263467ac2..b7c04d35306 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -1,8 +1,11 @@ """Tessie parent entity class.""" - +from collections.abc import Awaitable, Callable from typing import Any +from aiohttp import ClientResponseError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,3 +46,27 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): def _value(self) -> Any: """Return value from coordinator data.""" return self.coordinator.data[self.key] + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + async def run( + self, func: Callable[..., Awaitable[dict[str, bool]]], **kargs: Any + ) -> None: + """Run a tessie_api function and handle exceptions.""" + try: + await func( + session=self.coordinator.session, + vin=self.vin, + api_key=self.coordinator.api_key, + **kargs, + ) + except ClientResponseError as e: + raise HomeAssistantError from e + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 43ddd7b4954..978e594b68f 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -22,6 +22,21 @@ } }, "entity": { + "climate": { + "primary": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "on": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, "sensor": { "charge_state_usable_battery_level": { "name": "Battery level" diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py new file mode 100644 index 00000000000..341e4714470 --- /dev/null +++ b/tests/components/tessie/test_climate.py @@ -0,0 +1,124 @@ +"""Test the Tessie climate platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.tessie.const import TessieClimateKeeper +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + + +async def test_climate(hass: HomeAssistant) -> None: + """Tests that the climate entity is correct.""" + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_MIN_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["min_avail_temp"] + ) + assert ( + state.attributes.get(ATTR_MAX_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["max_avail_temp"] + ) + + # Test setting climate on + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate temp + with patch( + "homeassistant.components.tessie.climate.set_temperature", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate preset + with patch( + "homeassistant.components.tessie.climate.set_climate_keeper_mode", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: TessieClimateKeeper.ON}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate off + with patch( + "homeassistant.components.tessie.climate.stop_climate", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_set.assert_called_once() + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests virtual key error is handled.""" + + await setup_platform(hass) + entity_id = "climate.test_climate" + + # Test setting climate on with unknown error + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN From e2cf4244ea52e829e94ba20cca5e9a0ffbd30731 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 21 Dec 2023 15:34:52 +1000 Subject: [PATCH 567/927] Add switch platform to Tessie (#106153) * Add switch platform * Make functions mandatory * Underscores * Improvements --- homeassistant/components/tessie/__init__.py | 2 +- homeassistant/components/tessie/strings.json | 17 +++ homeassistant/components/tessie/switch.py | 121 +++++++++++++++++++ tests/components/tessie/test_switch.py | 53 ++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tessie/switch.py create mode 100644 tests/components/tessie/test_switch.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index fdffaffa538..65a695614b4 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 978e594b68f..02f22a6f55a 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -160,6 +160,23 @@ "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" } + }, + "switch": { + "charge_state_charge_enable_request": { + "name": "Charge" + }, + "climate_state_defrost_mode": { + "name": "Defrost mode" + }, + "vehicle_state_sentry_mode": { + "name": "Sentry mode" + }, + "vehicle_state_valet_mode": { + "name": "Valet mode" + }, + "climate_state_steering_wheel_heater": { + "name": "Steering wheel heater" + } } } } diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py new file mode 100644 index 00000000000..216f48da348 --- /dev/null +++ b/homeassistant/components/tessie/switch.py @@ -0,0 +1,121 @@ +"""Switch platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from tessie_api import ( + disable_sentry_mode, + disable_valet_mode, + enable_sentry_mode, + enable_valet_mode, + start_charging, + start_defrost, + start_steering_wheel_heater, + stop_charging, + stop_defrost, + stop_steering_wheel_heater, +) + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieSwitchEntityDescription(SwitchEntityDescription): + """Describes Tessie Switch entity.""" + + on_func: Callable + off_func: Callable + device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH + + +DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( + TessieSwitchEntityDescription( + key="charge_state_charge_enable_request", + on_func=start_charging, + off_func=stop_charging, + icon="mdi:ev-station", + ), + TessieSwitchEntityDescription( + key="climate_state_defrost_mode", + on_func=start_defrost, + off_func=stop_defrost, + icon="mdi:snowflake", + ), + TessieSwitchEntityDescription( + key="vehicle_state_sentry_mode", + on_func=enable_sentry_mode, + off_func=disable_sentry_mode, + icon="mdi:shield-car", + ), + TessieSwitchEntityDescription( + key="vehicle_state_valet_mode", + on_func=enable_valet_mode, + off_func=disable_valet_mode, + icon="mdi:car-key", + ), + TessieSwitchEntityDescription( + key="climate_state_steering_wheel_heater", + on_func=start_steering_wheel_heater, + off_func=stop_steering_wheel_heater, + icon="mdi:steering", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Switch platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + TessieSwitchEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ] + ) + + +class TessieSwitchEntity(TessieEntity, SwitchEntity): + """Base class for Tessie Switch.""" + + entity_description: TessieSwitchEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieSwitchEntityDescription, + ) -> None: + """Initialize the Switch.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the Switch.""" + return self._value + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + await self.run(self.entity_description.on_func) + self.set((self.entity_description.key, True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + await self.run(self.entity_description.off_func) + self.set((self.entity_description.key, False)) diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py new file mode 100644 index 00000000000..7ecd51bbd54 --- /dev/null +++ b/tests/components/tessie/test_switch.py @@ -0,0 +1,53 @@ +"""Test the Tessie switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.tessie.switch import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_switches(hass: HomeAssistant) -> None: + """Tests that the switches are correct.""" + + assert len(hass.states.async_all("switch")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("switch")) == len(DESCRIPTIONS) + + assert (hass.states.get("switch.test_charge").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_enable_request"] + ) + assert (hass.states.get("switch.test_sentry_mode").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["sentry_mode"] + ) + + with patch( + "homeassistant.components.tessie.entity.TessieEntity.run", + return_value=True, + ) as mock_run: + # Test Switch On + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_run.assert_called_once() + mock_run.reset_mock() + + # Test Switch Off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_run.assert_called_once() From aa51b5f6d458de0183e48583c89a614420ea1c10 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 20 Dec 2023 21:44:40 -0800 Subject: [PATCH 568/927] Add virtual integrations for AEP utilities supported by opower (#106146) --- homeassistant/components/aepohio/__init__.py | 1 + .../components/aepohio/manifest.json | 6 ++++ homeassistant/components/aeptexas/__init__.py | 1 + .../components/aeptexas/manifest.json | 6 ++++ .../components/appalachianpower/__init__.py | 1 + .../components/appalachianpower/manifest.json | 6 ++++ .../indianamichiganpower/__init__.py | 1 + .../indianamichiganpower/manifest.json | 6 ++++ .../components/kentuckypower/__init__.py | 1 + .../components/kentuckypower/manifest.json | 6 ++++ .../components/psoklahoma/__init__.py | 1 + .../components/psoklahoma/manifest.json | 6 ++++ homeassistant/components/swepco/__init__.py | 1 + homeassistant/components/swepco/manifest.json | 6 ++++ homeassistant/generated/integrations.json | 35 +++++++++++++++++++ 15 files changed, 84 insertions(+) create mode 100644 homeassistant/components/aepohio/__init__.py create mode 100644 homeassistant/components/aepohio/manifest.json create mode 100644 homeassistant/components/aeptexas/__init__.py create mode 100644 homeassistant/components/aeptexas/manifest.json create mode 100644 homeassistant/components/appalachianpower/__init__.py create mode 100644 homeassistant/components/appalachianpower/manifest.json create mode 100644 homeassistant/components/indianamichiganpower/__init__.py create mode 100644 homeassistant/components/indianamichiganpower/manifest.json create mode 100644 homeassistant/components/kentuckypower/__init__.py create mode 100644 homeassistant/components/kentuckypower/manifest.json create mode 100644 homeassistant/components/psoklahoma/__init__.py create mode 100644 homeassistant/components/psoklahoma/manifest.json create mode 100644 homeassistant/components/swepco/__init__.py create mode 100644 homeassistant/components/swepco/manifest.json diff --git a/homeassistant/components/aepohio/__init__.py b/homeassistant/components/aepohio/__init__.py new file mode 100644 index 00000000000..a602f1d794a --- /dev/null +++ b/homeassistant/components/aepohio/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Ohio.""" diff --git a/homeassistant/components/aepohio/manifest.json b/homeassistant/components/aepohio/manifest.json new file mode 100644 index 00000000000..f659a712016 --- /dev/null +++ b/homeassistant/components/aepohio/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aepohio", + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/aeptexas/__init__.py b/homeassistant/components/aeptexas/__init__.py new file mode 100644 index 00000000000..c8ff9829e22 --- /dev/null +++ b/homeassistant/components/aeptexas/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Texas.""" diff --git a/homeassistant/components/aeptexas/manifest.json b/homeassistant/components/aeptexas/manifest.json new file mode 100644 index 00000000000..d6260a2f51a --- /dev/null +++ b/homeassistant/components/aeptexas/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aeptexas", + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/appalachianpower/__init__.py b/homeassistant/components/appalachianpower/__init__.py new file mode 100644 index 00000000000..2e3180ba29f --- /dev/null +++ b/homeassistant/components/appalachianpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Appalachian Power.""" diff --git a/homeassistant/components/appalachianpower/manifest.json b/homeassistant/components/appalachianpower/manifest.json new file mode 100644 index 00000000000..884bd14c3fd --- /dev/null +++ b/homeassistant/components/appalachianpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "appalachianpower", + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/indianamichiganpower/__init__.py b/homeassistant/components/indianamichiganpower/__init__.py new file mode 100644 index 00000000000..06870a50604 --- /dev/null +++ b/homeassistant/components/indianamichiganpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Indiana Michigan Power.""" diff --git a/homeassistant/components/indianamichiganpower/manifest.json b/homeassistant/components/indianamichiganpower/manifest.json new file mode 100644 index 00000000000..ee6ff0402c7 --- /dev/null +++ b/homeassistant/components/indianamichiganpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "indianamichiganpower", + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/kentuckypower/__init__.py b/homeassistant/components/kentuckypower/__init__.py new file mode 100644 index 00000000000..cc4ab179682 --- /dev/null +++ b/homeassistant/components/kentuckypower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Kentucky Power.""" diff --git a/homeassistant/components/kentuckypower/manifest.json b/homeassistant/components/kentuckypower/manifest.json new file mode 100644 index 00000000000..300cfd7dd9d --- /dev/null +++ b/homeassistant/components/kentuckypower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "kentuckypower", + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/psoklahoma/__init__.py b/homeassistant/components/psoklahoma/__init__.py new file mode 100644 index 00000000000..a0a3a4ca0bb --- /dev/null +++ b/homeassistant/components/psoklahoma/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Public Service Company of Oklahoma (PSO).""" diff --git a/homeassistant/components/psoklahoma/manifest.json b/homeassistant/components/psoklahoma/manifest.json new file mode 100644 index 00000000000..5a1aa460dd0 --- /dev/null +++ b/homeassistant/components/psoklahoma/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "psoklahoma", + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/swepco/__init__.py b/homeassistant/components/swepco/__init__.py new file mode 100644 index 00000000000..6a1bcc0209a --- /dev/null +++ b/homeassistant/components/swepco/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Southwestern Electric Power Company (SWEPCO).""" diff --git a/homeassistant/components/swepco/manifest.json b/homeassistant/components/swepco/manifest.json new file mode 100644 index 00000000000..115060b7e3f --- /dev/null +++ b/homeassistant/components/swepco/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "swepco", + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 49c97002fc2..448b69e6da7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,6 +65,16 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "aepohio": { + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" + }, + "aeptexas": { + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" + }, "aftership": { "name": "AfterShip", "integration_type": "hub", @@ -304,6 +314,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "appalachianpower": { + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "apple": { "name": "Apple", "integrations": { @@ -2654,6 +2669,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "indianamichiganpower": { + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "influxdb": { "name": "InfluxDB", "integration_type": "hub", @@ -2855,6 +2875,11 @@ "config_flow": true, "iot_class": "local_push" }, + "kentuckypower": { + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "keyboard": { "name": "Keyboard", "integration_type": "hub", @@ -4476,6 +4501,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "psoklahoma": { + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -5569,6 +5599,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "swepco": { + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "swiss_hydrological_data": { "name": "Swiss Hydrological Data", "integration_type": "hub", From 126f0e40473bf5cc6656b667a6776f9a62c7caa8 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 21 Dec 2023 00:18:49 -0600 Subject: [PATCH 569/927] Bump life360 to 6.0.1 (#106149) * Bump life360 package to 6.0.1 Fix recent API issues. * Update requirements files --- homeassistant/components/life360/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json index 18b83013d70..481d006809d 100644 --- a/homeassistant/components/life360/manifest.json +++ b/homeassistant/components/life360/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/life360", "iot_class": "cloud_polling", "loggers": ["life360"], - "requirements": ["life360==6.0.0"] + "requirements": ["life360==6.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index df39934a6df..09ccea97ec7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1177,7 +1177,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==6.0.0 +life360==6.0.1 # homeassistant.components.osramlightify lightify==1.0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fa863691d2..a08abd5fd95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -928,7 +928,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==6.0.0 +life360==6.0.1 # homeassistant.components.linear_garage_door linear-garage-door==0.2.7 From dbb726f41f6779f7ae225456412347aea8c46f4d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 21 Dec 2023 16:34:31 +1000 Subject: [PATCH 570/927] Add Select platform to Tessie (#105423) * Add select platform * Add error coverage * Fix case * fix value * Remove virtual key issue * Add TessieSeatHeaterOptions enum and update TessieSeatHeaterSelectEntity options * use ENUM in tests * Porting other fixes * Update entity --- homeassistant/components/tessie/__init__.py | 8 ++- homeassistant/components/tessie/const.py | 9 +++ homeassistant/components/tessie/select.py | 58 +++++++++++++++++ homeassistant/components/tessie/strings.json | 65 ++++++++++++++++++++ tests/components/tessie/test_select.py | 65 ++++++++++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tessie/select.py create mode 100644 tests/components/tessie/test_select.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 65a695614b4..d34a1335ebc 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -14,7 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index b7dcaea4420..43f9b2c719b 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -20,6 +20,15 @@ class TessieStatus(StrEnum): ONLINE = "online" +class TessieSeatHeaterOptions(StrEnum): + """Tessie seat heater options.""" + + OFF = "off" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + class TessieClimateKeeper(StrEnum): """Tessie Climate Keeper Modes.""" diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py new file mode 100644 index 00000000000..d40abed6478 --- /dev/null +++ b/homeassistant/components/tessie/select.py @@ -0,0 +1,58 @@ +"""Select platform for Tessie integration.""" +from __future__ import annotations + +from tessie_api import set_seat_heat + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieSeatHeaterOptions +from .entity import TessieEntity + +SEAT_HEATERS = { + "climate_state_seat_heater_left": "front_left", + "climate_state_seat_heater_right": "front_right", + "climate_state_seat_heater_rear_left": "rear_left", + "climate_state_seat_heater_rear_center": "rear_center", + "climate_state_seat_heater_rear_right": "rear_right", + "climate_state_seat_heater_third_row_left": "third_row_left", + "climate_state_seat_heater_third_row_right": "third_row_right", +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie select platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSeatHeaterSelectEntity(coordinator, key) + for coordinator in coordinators + for key in SEAT_HEATERS + if key in coordinator.data + ) + + +class TessieSeatHeaterSelectEntity(TessieEntity, SelectEntity): + """Select entity for current charge.""" + + _attr_options = [ + TessieSeatHeaterOptions.OFF, + TessieSeatHeaterOptions.LOW, + TessieSeatHeaterOptions.MEDIUM, + TessieSeatHeaterOptions.HIGH, + ] + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._attr_options[self._value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + level = self._attr_options.index(option) + await self.run(set_seat_heat, seat=SEAT_HEATERS[self.key], level=level) + self.set((self.key, level)) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 02f22a6f55a..f1279ab0daf 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -102,6 +102,71 @@ "name": "Passenger temperature setting" } }, + "select": { + "climate_state_seat_heater_left": { + "name": "Seat heater left", + "state": { + "off": "[%key:common::state::off%]", + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "climate_state_seat_heater_right": { + "name": "Seat heater right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_left": { + "name": "Seat heater rear left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_center": { + "name": "Seat heater rear center", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_right": { + "name": "Seat heater rear right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_left": { + "name": "Seat heater third row left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_right": { + "name": "Seat heater third row right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + } + }, "binary_sensor": { "state": { "name": "Status" diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py new file mode 100644 index 00000000000..705e66d3dbb --- /dev/null +++ b/tests/components/tessie/test_select.py @@ -0,0 +1,65 @@ +"""Test the Tessie select platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tessie.const import TessieSeatHeaterOptions +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform + + +async def test_select(hass: HomeAssistant) -> None: + """Tests that the select entity is correct.""" + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 5 + + entity_id = "select.test_seat_heater_left" + assert hass.states.get(entity_id).state == STATE_OFF + + # Test changing select + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert mock_set.call_args[1]["seat"] == "front_left" + assert mock_set.call_args[1]["level"] == 1 + assert hass.states.get(entity_id).state == TessieSeatHeaterOptions.LOW + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests unknown error is handled.""" + + await setup_platform(hass) + entity_id = "select.test_seat_heater_left" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN From e50fe799114610c05362937790eace4857a733f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 20:37:39 -1000 Subject: [PATCH 571/927] Update habluetooth to 2.0.0 (#106145) --- .../components/bluetooth/__init__.py | 2 +- homeassistant/components/bluetooth/api.py | 5 +--- homeassistant/components/bluetooth/manager.py | 5 +--- .../components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/bluetooth.py | 2 +- .../components/ruuvi_gateway/bluetooth.py | 2 +- .../components/shelly/bluetooth/__init__.py | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bluetooth/test_advertisement_tracker.py | 2 +- tests/components/bluetooth/test_api.py | 8 +++---- .../components/bluetooth/test_base_scanner.py | 24 +++++++++---------- .../components/bluetooth/test_diagnostics.py | 12 +++++----- tests/components/bluetooth/test_init.py | 4 ++-- tests/components/bluetooth/test_manager.py | 20 +++++++--------- tests/components/bluetooth/test_models.py | 13 +++++----- tests/components/bluetooth/test_wrappers.py | 4 ++-- 18 files changed, 53 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 234712bddaf..2dd4f06ecdf 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -303,7 +303,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS - entry.async_on_unload(async_register_scanner(hass, scanner, True, slots)) + entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots)) await async_update_device(hass, entry, adapter, details) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 4acb8d91c84..29054a54e72 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -181,13 +181,10 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None: def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner( - scanner, connectable, connection_slots - ) + return _get_manager(hass).async_register_scanner(scanner, connection_slots) @hass_callback diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 5508f58c82b..381beb02520 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -220,7 +220,6 @@ class HomeAssistantBluetoothManager(BluetoothManager): def async_register_scanner( self, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" @@ -228,7 +227,5 @@ class HomeAssistantBluetoothManager(BluetoothManager): if history := self.storage.async_get_advertisement_history(scanner.source): scanner.restore_discovered_devices(history) - unregister = super().async_register_scanner( - scanner, connectable, connection_slots - ) + unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b5bce32148a..e911038b9f6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.18.0", "dbus-fast==2.21.0", - "habluetooth==1.0.0" + "habluetooth==2.0.0" ] } diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 9534074b678..24524233a70 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -38,7 +38,7 @@ async def async_connect_scanner( return partial( _async_unload, [ - async_register_scanner(hass, scanner, scanner.connectable), + async_register_scanner(hass, scanner), scanner.async_setup(), ], ) diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index d3cf1e81379..c4fbe474776 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -84,7 +84,7 @@ def async_connect_scanner( coordinator=coordinator, ) unload_callbacks = [ - async_register_scanner(hass, scanner, connectable=False), + async_register_scanner(hass, scanner), scanner.async_setup(), scanner.start_polling(), ] diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 92c630323ba..2f9019ba5e6 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -43,7 +43,7 @@ async def async_connect_scanner( ) scanner = ShellyBLEScanner(source, entry.title, connector, False) unload_callbacks = [ - async_register_scanner(hass, scanner, False), + async_register_scanner(hass, scanner), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 50b6fe01a6b..5a43dbe2f06 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==1.0.0 +habluetooth==2.0.0 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 09ccea97ec7..f685fa29564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -989,7 +989,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==1.0.0 +habluetooth==2.0.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a08abd5fd95..334bf7d2044 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -788,7 +788,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==1.0.0 +habluetooth==2.0.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 190b05e60e8..f90b82fc379 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -346,7 +346,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c switchbot_device_went_unavailable = False scanner = FakeScanner("new", "fake_adapter") - cancel_scanner = async_register_scanner(hass, scanner, False) + cancel_scanner = async_register_scanner(hass, scanner) @callback def _switchbot_device_unavailable_callback(_address: str) -> None: diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 732fce4c8e2..a42752dcfc7 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -28,7 +28,7 @@ async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> """Test we can get a scanner by source.""" hci2_scanner = FakeScanner("hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) assert async_scanner_by_source(hass, "hci2") is hci2_scanner cancel_hci2() @@ -74,9 +74,9 @@ async def test_async_scanner_devices_by_address_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeInjectableScanner("esp32", "esp32", connector, False) + scanner = FakeInjectableScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", @@ -141,7 +141,7 @@ async def test_async_scanner_devices_by_address_non_connectable( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) scanner = FakeStaticScanner("esp32", "esp32", connector) - cancel = manager.async_register_scanner(scanner, False) + cancel = manager.async_register_scanner(scanner) assert scanner.discovered_devices_and_advertisement_data == { switchbot_device.address: (switchbot_device, switchbot_device_adv) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 4f60fc9ef9b..e1d64115e86 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -116,7 +116,7 @@ async def test_remote_scanner( ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -182,7 +182,7 @@ async def test_remote_scanner_expires_connectable( ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -234,9 +234,9 @@ async def test_remote_scanner_expires_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -308,9 +308,9 @@ async def test_base_scanner_connecting_behavior( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False @@ -366,7 +366,7 @@ async def test_restore_history_remote_adapter( True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -380,7 +380,7 @@ async def test_restore_history_remote_adapter( True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -410,9 +410,9 @@ async def test_device_with_ten_minute_advertising_interval( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) monotonic_now = time.monotonic() new_time = monotonic_now @@ -501,9 +501,9 @@ async def test_scanner_stops_responding( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index f70c301dcfe..a8e693c3f99 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -457,9 +457,9 @@ async def test_diagnostics_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner("esp32", "esp32", connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -511,7 +511,7 @@ async def test_diagnostics_remote_adapter( -127, [], ], - "connectable": False, + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", @@ -537,7 +537,7 @@ async def test_diagnostics_remote_adapter( [], -127, -127, - [[]], + [], ], "connectable": True, "device": { @@ -551,7 +551,7 @@ async def test_diagnostics_remote_adapter( "rssi": -127, "service_data": {}, "service_uuids": [], - "source": "local", + "source": "esp32", "time": ANY, } ], @@ -595,7 +595,7 @@ async def test_diagnostics_remote_adapter( "type": "FakeHaScanner", }, { - "connectable": False, + "connectable": True, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 52624e67996..1659b989af0 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2816,7 +2816,7 @@ async def test_scanner_count_connectable( ) -> None: """Test getting the connectable scanner count.""" scanner = FakeScanner("any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2824,7 +2824,7 @@ async def test_scanner_count_connectable( async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" scanner = FakeScanner("any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 2a470feacfa..212f45bb5f0 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -55,7 +55,7 @@ from tests.common import async_fire_time_changed, load_fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) yield cancel() @@ -64,7 +64,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) yield cancel() @@ -559,9 +559,7 @@ async def test_switching_adapters_when_one_goes_away( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when one goes away.""" - cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner("hci2", "hci2"), True - ) + cancel_hci2 = bluetooth.async_register_scanner(hass, FakeScanner("hci2", "hci2")) address = "44:44:33:11:23:45" @@ -611,7 +609,7 @@ async def test_switching_adapters_when_one_stop_scanning( ) -> None: """Test switching adapters when stops scanning.""" hci2_scanner = FakeScanner("hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) address = "44:44:33:11:23:45" @@ -730,7 +728,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( ) unsetup_connectable_scanner = connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -752,7 +750,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( ) unsetup_not_connectable_scanner = not_connectable_scanner.async_setup() cancel_not_connectable_scanner = _get_manager().async_register_scanner( - not_connectable_scanner, False + not_connectable_scanner ) not_connectable_scanner.inject_advertisement( switchbot_device_non_connectable, switchbot_device_adv @@ -801,7 +799,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( ) unsetup_connectable_scanner_2 = connectable_scanner_2.async_setup() cancel_connectable_scanner_2 = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner_2.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -902,7 +900,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( ) unsetup_connectable_scanner = non_connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - non_connectable_scanner, True + non_connectable_scanner ) with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: non_connectable_scanner.inject_advertisement( @@ -914,7 +912,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None - assert async_scanner_count(hass, connectable=True) == 1 + assert async_scanner_count(hass, connectable=False) == 1 assert len(callbacks) == 1 assert ( diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 6e8181b5a22..9b513ed2197 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -107,7 +107,8 @@ async def test_wrapped_bleak_client_local_adapter_only( "00:00:00:00:00:01", "hci0", ) - cancel = manager.async_register_scanner(scanner, True) + scanner.connectable = True + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -187,7 +188,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -291,7 +292,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False) scanner = FakeScanner("esp32", "esp32", connector, True) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" ) @@ -356,7 +357,7 @@ async def test_ble_device_with_proxy_clear_cache( connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) scanner = FakeScanner("esp32", "esp32", connector, True) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32" ) @@ -466,7 +467,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] @@ -578,7 +579,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 78ec5bd16ac..cc837f381d4 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -187,8 +187,8 @@ def _generate_scanners_with_fake_devices(hass): for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) - cancel_hci0 = manager.async_register_scanner(scanner_hci0, True, 2) - cancel_hci1 = manager.async_register_scanner(scanner_hci1, True, 1) + cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) + cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1) return hci0_device_advs, cancel_hci0, cancel_hci1 From bfaae77e5123c122be111f53fde22cce5a67c153 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 21 Dec 2023 01:44:36 -0500 Subject: [PATCH 572/927] Bump ZHA dependencies (#106147) --- homeassistant/components/zha/manifest.json | 10 +++++----- requirements_all.txt | 10 +++++----- requirements_test_all.txt | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fe58ff044cd..a2965e782f4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,15 +21,15 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.3", + "bellows==0.37.4", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.107", - "zigpy-deconz==0.22.2", - "zigpy==0.60.1", + "zha-quirks==0.0.108", + "zigpy-deconz==0.22.3", + "zigpy==0.60.2", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", - "zigpy-znp==0.12.0", + "zigpy-znp==0.12.1", "universal-silabs-flasher==0.0.15", "pyserial-asyncio-fast==0.11" ], diff --git a/requirements_all.txt b/requirements_all.txt index f685fa29564..5f0233fac65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.3 +bellows==0.37.4 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2843,7 +2843,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.107 +zha-quirks==0.0.108 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2852,7 +2852,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.2 +zigpy-deconz==0.22.3 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2861,10 +2861,10 @@ zigpy-xbee==0.20.1 zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.12.0 +zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.1 +zigpy==0.60.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 334bf7d2044..b226e451531 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.3 +bellows==0.37.4 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2141,10 +2141,10 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.107 +zha-quirks==0.0.108 # homeassistant.components.zha -zigpy-deconz==0.22.2 +zigpy-deconz==0.22.3 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2153,10 +2153,10 @@ zigpy-xbee==0.20.1 zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.12.0 +zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.1 +zigpy==0.60.2 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 From 48241771f6a20b6929043aafc777ed9e4888c005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 21 Dec 2023 03:45:41 -0300 Subject: [PATCH 573/927] Bump SunWEG to 2.0.3 (#106135) * chore(sunweg): bump version * chore(sunweg): bump 2.0.3 --- homeassistant/components/sunweg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 7f03dec11b0..de0b3406f05 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunweg/", "iot_class": "cloud_polling", "loggers": ["sunweg"], - "requirements": ["sunweg==2.0.1"] + "requirements": ["sunweg==2.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f0233fac65..0d539159887 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2558,7 +2558,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.1 +sunweg==2.0.3 # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b226e451531..3935d739176 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.1 +sunweg==2.0.3 # homeassistant.components.surepetcare surepy==0.9.0 From 037eb33710094df5d0cf00d434febb86bc38eb87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Dec 2023 08:11:27 +0100 Subject: [PATCH 574/927] Refactor media_player test classes (#105630) --- .../media_player/test_async_helpers.py | 108 +++++------------- 1 file changed, 28 insertions(+), 80 deletions(-) diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index a24c9cc76b2..f3b70187f33 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -13,86 +13,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant -class ExtendedMediaPlayer(mp.MediaPlayerEntity): - """Media player test class.""" - - def __init__(self, hass): - """Initialize the test media player.""" - self.hass = hass - self._volume = 0 - self._state = STATE_OFF - - @property - def state(self): - """State of the player.""" - return self._state - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return ( - mp.const.MediaPlayerEntityFeature.VOLUME_SET - | mp.const.MediaPlayerEntityFeature.VOLUME_STEP - | mp.const.MediaPlayerEntityFeature.PLAY - | mp.const.MediaPlayerEntityFeature.PAUSE - | mp.const.MediaPlayerEntityFeature.TURN_OFF - | mp.const.MediaPlayerEntityFeature.TURN_ON - ) - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._volume = volume - - def volume_up(self): - """Turn volume up for media player.""" - if self.volume_level < 1: - self.set_volume_level(min(1, self.volume_level + 0.1)) - - def volume_down(self): - """Turn volume down for media player.""" - if self.volume_level > 0: - self.set_volume_level(max(0, self.volume_level - 0.1)) - - def media_play(self): - """Play the media player.""" - self._state = STATE_PLAYING - - def media_pause(self): - """Plause the media player.""" - self._state = STATE_PAUSED - - def media_play_pause(self): - """Play or pause the media player.""" - if self._state == STATE_PLAYING: - self._state = STATE_PAUSED - else: - self._state = STATE_PLAYING - - def turn_on(self): - """Turn on state.""" - self._state = STATE_ON - - def turn_off(self): - """Turn off state.""" - self._state = STATE_OFF - - def standby(self): - """Put device in standby.""" - self._state = STATE_STANDBY - - def toggle(self): - """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: - self._state = STATE_ON - else: - self._state = STATE_OFF - - class SimpleMediaPlayer(mp.MediaPlayerEntity): """Media player test class.""" @@ -149,6 +69,34 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): self._state = STATE_STANDBY +class ExtendedMediaPlayer(SimpleMediaPlayer): + """Media player test class.""" + + def volume_up(self): + """Turn volume up for media player.""" + if self.volume_level < 1: + self.set_volume_level(min(1, self.volume_level + 0.1)) + + def volume_down(self): + """Turn volume down for media player.""" + if self.volume_level > 0: + self.set_volume_level(max(0, self.volume_level - 0.1)) + + def media_play_pause(self): + """Play or pause the media player.""" + if self._state == STATE_PLAYING: + self._state = STATE_PAUSED + else: + self._state = STATE_PLAYING + + def toggle(self): + """Toggle the power on the media player.""" + if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + self._state = STATE_ON + else: + self._state = STATE_OFF + + class AttrMediaPlayer(SimpleMediaPlayer): """Media player setting properties via _attr_*.""" From 6845218a2472202ac36e56c8e91f5f0f895d6720 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 21:13:59 -1000 Subject: [PATCH 575/927] Bump bluetooth-data-tools to 1.19.0 (#106156) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/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 e911038b9f6..33404a762b9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.2", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.18.0", + "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", "habluetooth==2.0.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index c0c7d29394d..8b220f78e53 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.18.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.19.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6185110d795..9a496dbd049 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.18.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.19.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 2ddc5b582f6..bc4ad0f2912 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.18.0"] + "requirements": ["bluetooth-data-tools==1.19.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a43dbe2f06..721801c176d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.18.0 +bluetooth-data-tools==1.19.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index 0d539159887..b7dbca06daf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -569,7 +569,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.18.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3935d739176..51d0bac7663 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,7 +481,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.18.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 From 1d63c348168528265e65178cd848e44911a0f7bc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 21 Dec 2023 18:15:51 +1000 Subject: [PATCH 576/927] Add flags to EntityDescriptions in Tessie (#105847) * Add SensorEntityDescription flags * fix datclass attributes --- homeassistant/components/tessie/binary_sensor.py | 2 +- homeassistant/components/tessie/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 95cf789b694..38473d1076b 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -19,7 +19,7 @@ from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Tessie binary sensor entity.""" diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 6c39d2a8e77..9023a3319ea 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -32,7 +32,7 @@ from .coordinator import TessieDataUpdateCoordinator from .entity import TessieEntity -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" From 235914c63ac584022da8f8caf73e8064e536466c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Dec 2023 22:28:04 -1000 Subject: [PATCH 577/927] Improve performance of dhcp integration client processing (#106137) We were using run_callback_threadsafe here which has the overhead of creating a future and waiting for the result when we throw it away. --- homeassistant/components/dhcp/__init__.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index df95e629b8f..2afe53422fb 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -57,7 +57,6 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from homeassistant.util.async_ import run_callback_threadsafe from .const import DOMAIN @@ -145,13 +144,9 @@ class WatcherBase(ABC): def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None: """Process a client.""" - return run_callback_threadsafe( - self.hass.loop, - self.async_process_client, - ip_address, - hostname, - mac_address, - ).result() + self.hass.loop.call_soon_threadsafe( + self.async_process_client, ip_address, hostname, mac_address + ) @callback def async_process_client( From 46d63ad7ba6bc8945c4ada84c024788685edb436 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 21 Dec 2023 10:02:38 +0100 Subject: [PATCH 578/927] Allow Fronius devices to be deleted (#106141) --- homeassistant/components/fronius/__init__.py | 7 +++++ tests/components/fronius/__init__.py | 14 +++++++++ tests/components/fronius/test_init.py | 32 ++++++++++++++++++-- tests/components/fronius/test_sensor.py | 6 ++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c05f18107a0..d0e13aa7914 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -65,6 +65,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + class FroniusSolarNet: """The FroniusSolarNet class routes received values to sensor entities.""" diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 1255ba79388..2e053f7ccc5 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -125,3 +125,17 @@ async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_upd freezer.tick(time_till_next_update) async_fire_time_changed(hass) await hass.async_block_till_done() + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index cc56fea24b2..f8d86bac26a 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -7,13 +7,15 @@ from pyfronius import FroniusError from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import mock_responses, setup_fronius_integration +from . import mock_responses, remove_device, setup_fronius_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_unload_config_entry( @@ -138,3 +140,29 @@ async def test_inverter_rescan_interruption( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 2 ) + + +async def test_device_remove_devices( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can remove a device.""" + assert await async_setup_component(hass, "config", {}) + + mock_responses(aioclient_mock, fixture_set="gen24_storage") + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) + assert ( + await remove_device( + await hass_ws_client(hass), inverter_1.id, config_entry.entry_id + ) + is True + ) + + assert not device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 684e9a3ae5f..a8f48ce2e88 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -370,6 +370,7 @@ async def test_gen24( async def test_gen24_storage( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -465,8 +466,6 @@ async def test_gen24_storage( assert_state("sensor.byd_battery_box_premium_hv_dc_voltage", 0.0) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_12345678")} ) @@ -501,6 +500,7 @@ async def test_gen24_storage( async def test_primo_s0( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -573,8 +573,6 @@ async def test_primo_s0( assert_state("sensor.solarnet_energy_year", 11128933.25) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_123.4567890")} ) From 0614e291c1423afb1a341a0fee5227bb069d8b7c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 21 Dec 2023 10:29:01 +0100 Subject: [PATCH 579/927] Set WiFi QR code entity to unknown when Fritzbox is not available (#105870) --- homeassistant/components/fritz/image.py | 10 +++++- tests/components/fritz/test_image.py | 43 ++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d14c562bd76..aa1ede5a185 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -5,6 +5,8 @@ from __future__ import annotations from io import BytesIO import logging +from requests.exceptions import RequestException + from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -78,7 +80,13 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity): async def async_update(self) -> None: """Update the image entity data.""" - qr_bytes = await self._fetch_image() + try: + qr_bytes = await self._fetch_image() + except RequestException: + self._current_qr_bytes = None + self._attr_image_last_updated = None + self.async_write_ha_state() + return if self._current_qr_bytes != qr_bytes: dt_now = dt_util.utcnow() diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index cbcbded5692..da5b8a76d27 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -4,12 +4,13 @@ from http import HTTPStatus from unittest.mock import patch import pytest +from requests.exceptions import ReadTimeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.setup import async_setup_component @@ -170,3 +171,43 @@ async def test_image_update( assert resp_body != resp_body_new assert resp_body_new == snapshot + + +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +async def test_image_update_unavailable( + hass: HomeAssistant, + fc_class_mock, + fh_class_mock, +) -> None: + """Test image update when fritzbox is unavailable.""" + + # setup component with image platform only + with patch( + "homeassistant.components.fritz.PLATFORMS", + [Platform.IMAGE], + ): + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + state = hass.states.get("image.mock_title_guestwifi") + assert state + + # fritzbox becomes unavailable + fc_class_mock().call_action_side_effect(ReadTimeout) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + state = hass.states.get("image.mock_title_guestwifi") + assert state.state == STATE_UNKNOWN + + # fritzbox is available again + fc_class_mock().call_action_side_effect(None) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + state = hass.states.get("image.mock_title_guestwifi") + assert state.state != STATE_UNKNOWN From f8f31627ce352902195c5f0b8a8b3f0c0d56e2ab Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Dec 2023 11:54:05 +0100 Subject: [PATCH 580/927] Refactor ranging utils for mqtt cover (#105739) * Refactor ranging utils for mqtt cover * Use literals for default min and max percentage --- homeassistant/components/mqtt/cover.py | 158 +++---- tests/components/mqtt/test_cover.py | 545 +++++++++---------------- 2 files changed, 254 insertions(+), 449 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 912de7e367b..dce82774205 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -32,6 +32,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import subscription from .config import MQTT_BASE_SCHEMA @@ -241,6 +245,10 @@ class MqttCover(MqttEntity, CoverEntity): _entity_id_format: str = cover.ENTITY_ID_FORMAT _optimistic: bool _tilt_optimistic: bool + _tilt_closed_percentage: int + _tilt_open_percentage: int + _pos_range: tuple[int, int] + _tilt_range: tuple[int, int] @staticmethod def config_schema() -> vol.Schema: @@ -248,6 +256,15 @@ class MqttCover(MqttEntity, CoverEntity): return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: + """Set up cover from config.""" + self._pos_range = (config[CONF_POSITION_CLOSED] + 1, config[CONF_POSITION_OPEN]) + self._tilt_range = (config[CONF_TILT_MIN] + 1, config[CONF_TILT_MAX]) + self._tilt_closed_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_CLOSED_POSITION] + ) + self._tilt_open_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_OPEN_POSITION] + ) no_position = ( config.get(CONF_SET_POSITION_TOPIC) is None and config.get(CONF_GET_POSITION_TOPIC) is None @@ -286,23 +303,22 @@ class MqttCover(MqttEntity, CoverEntity): ) template_config_attributes = { - "position_open": self._config[CONF_POSITION_OPEN], - "position_closed": self._config[CONF_POSITION_CLOSED], - "tilt_min": self._config[CONF_TILT_MIN], - "tilt_max": self._config[CONF_TILT_MAX], + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + "tilt_min": config[CONF_TILT_MIN], + "tilt_max": config[CONF_TILT_MAX], } self._value_template = MqttValueTemplate( - self._config.get(CONF_VALUE_TEMPLATE), - entity=self, + config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value self._set_position_template = MqttCommandTemplate( - self._config.get(CONF_SET_POSITION_TEMPLATE), entity=self + config.get(CONF_SET_POSITION_TEMPLATE), entity=self ).async_render self._get_position_template = MqttValueTemplate( - self._config.get(CONF_GET_POSITION_TEMPLATE), + config.get(CONF_GET_POSITION_TEMPLATE), entity=self, config_attributes=template_config_attributes, ).async_render_with_possible_json_value @@ -445,19 +461,17 @@ class MqttCover(MqttEntity, CoverEntity): payload = payload_dict["position"] try: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD + percentage_payload = ranged_value_to_percentage( + self._pos_range, float(payload) ) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", payload) return - self._attr_current_cover_position = percentage_payload + self._attr_current_cover_position = min(100, max(0, percentage_payload)) if self._config.get(CONF_STATE_TOPIC) is None: self._update_state( - STATE_CLOSED - if percentage_payload == DEFAULT_POSITION_CLOSED - else STATE_OPEN + STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN ) if self._config.get(CONF_GET_POSITION_TOPIC): @@ -508,9 +522,7 @@ class MqttCover(MqttEntity, CoverEntity): # Optimistically assume that cover has changed state. self._update_state(STATE_OPEN) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_OPEN], COVER_PAYLOAD - ) + self._attr_current_cover_position = 100 self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: @@ -529,9 +541,7 @@ class MqttCover(MqttEntity, CoverEntity): # Optimistically assume that cover has changed state. self._update_state(STATE_CLOSED) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD - ) + self._attr_current_cover_position = 0 self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -567,9 +577,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_OPEN_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_open_percentage self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs: Any) -> None: @@ -594,58 +602,60 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_closed_percentage self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt = kwargs[ATTR_TILT_POSITION] - percentage_tilt = tilt - tilt = self.find_in_range_from_percent(tilt) + tilt_percentage = kwargs[ATTR_TILT_POSITION] + tilt_ranged = round( + percentage_to_ranged_value(self._tilt_range, tilt_percentage) + ) # Handover the tilt after calculated from percent would make it more # consistent with receiving templates variables = { - "tilt_position": percentage_tilt, + "tilt_position": tilt_percentage, "entity_id": self.entity_id, "position_open": self._config.get(CONF_POSITION_OPEN), "position_closed": self._config.get(CONF_POSITION_CLOSED), "tilt_min": self._config.get(CONF_TILT_MIN), "tilt_max": self._config.get(CONF_TILT_MAX), } - tilt = self._set_tilt_template(tilt, variables=variables) + tilt_rendered = self._set_tilt_template(tilt_ranged, variables=variables) await self.async_publish( self._config[CONF_TILT_COMMAND_TOPIC], - tilt, + tilt_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") - self._attr_current_cover_tilt_position = percentage_tilt + self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = kwargs[ATTR_POSITION] - percentage_position = position - position = self.find_in_range_from_percent(position, COVER_PAYLOAD) + position_percentage = kwargs[ATTR_POSITION] + position_ranged = round( + percentage_to_ranged_value(self._pos_range, position_percentage) + ) variables = { - "position": percentage_position, + "position": position_percentage, "entity_id": self.entity_id, "position_open": self._config[CONF_POSITION_OPEN], "position_closed": self._config[CONF_POSITION_CLOSED], "tilt_min": self._config[CONF_TILT_MIN], "tilt_max": self._config[CONF_TILT_MAX], } - position = self._set_position_template(position, variables=variables) + position_rendered = self._set_position_template( + position_ranged, variables=variables + ) await self.async_publish( self._config[CONF_SET_POSITION_TOPIC], - position, + position_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], @@ -653,87 +663,37 @@ class MqttCover(MqttEntity, CoverEntity): if self._optimistic: self._update_state( STATE_CLOSED - if percentage_position == self._config[CONF_POSITION_CLOSED] + if position_percentage <= self._config[CONF_POSITION_CLOSED] else STATE_OPEN ) - self._attr_current_cover_position = percentage_position + self._attr_current_cover_position = position_percentage self.async_write_ha_state() async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" - if self.is_tilt_closed(): + if ( + self.current_cover_tilt_position is not None + and self.current_cover_tilt_position <= self._tilt_closed_percentage + ): await self.async_open_cover_tilt(**kwargs) else: await self.async_close_cover_tilt(**kwargs) - def is_tilt_closed(self) -> bool: - """Return if the cover is tilted closed.""" - return self._attr_current_cover_tilt_position == self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) - - def find_percentage_in_range( - self, position: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the 0-100% value within the specified range.""" - # the range of motion as defined by the min max values - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - current_range = max_range - min_range - # offset to be zero based - offset_position = position - min_range - position_percentage = round(float(offset_position) / current_range * 100.0) - - max_percent = 100 - min_percent = 0 - position_percentage = min(max(position_percentage, min_percent), max_percent) - - return position_percentage - - def find_in_range_from_percent( - self, percentage: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the adjusted value for 0-100% within the specified range. - - if the range is 80-180 and the percentage is 90 - this method would determine the value to send on the topic - by offsetting the max and min, getting the percentage value and - returning the offset - """ - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - offset = min_range - current_range = max_range - min_range - position = round(current_range * (percentage / 100.0)) - position += offset - - return position - @callback def tilt_payload_received(self, _payload: Any) -> None: """Set the tilt value.""" try: - payload = int(round(float(_payload))) + payload = round(float(_payload)) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", _payload) return if ( - self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX] - or self._config[CONF_TILT_MAX] - <= int(payload) - <= self._config[CONF_TILT_MIN] + self._config[CONF_TILT_MIN] <= payload <= self._config[CONF_TILT_MAX] + or self._config[CONF_TILT_MAX] <= payload <= self._config[CONF_TILT_MIN] ): - level = self.find_percentage_in_range(payload) + level = ranged_value_to_percentage(self._tilt_range, payload) self._attr_current_cover_tilt_position = level else: _LOGGER.warning( diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index c1732003fc0..df7b7a64b3d 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -23,7 +23,6 @@ from homeassistant.components.mqtt.cover import ( CONF_TILT_STATUS_TEMPLATE, CONF_TILT_STATUS_TOPIC, MQTT_COVER_ATTRIBUTES_BLOCKED, - MqttCover, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -197,8 +196,21 @@ async def test_opening_and_closing_state_via_custom_state_payload( } ], ) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, ) -> None: """Test the state after setting the position using optimistic mode.""" await mqtt_mock_entry() @@ -209,24 +221,201 @@ async def test_open_closed_state_from_position_optimistic( await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 0}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_CLOSED + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "position-topic", + "set_position_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "position_closed": 10, + "position_open": 90, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_CLOSED), + (10, STATE_CLOSED), + (11, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) +async def test_open_closed_state_from_position_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, +) -> None: + """Test the state after setting the position. + + Test with alt opened and closed positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 100}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_OPEN + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 100), (1, 0), (99, 0), (100, 0)], +) +async def test_tilt_open_closed_toggle_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "tilt_min": 5, + "tilt_max": 95, + "tilt_closed_value": 15, + "tilt_opened_value": 85, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 88), (11, 88), (12, 11), (30, 11), (90, 11), (100, 11)], +) +async def test_tilt_open_closed_toggle_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test with alt opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position @pytest.mark.parametrize( @@ -2236,350 +2425,6 @@ async def test_tilt_position_altered_range( ) -async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: - """Test find percentage in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 44 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 44 - - -async def test_find_percentage_in_range_altered(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 40 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 40 - - -async def test_find_percentage_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 56 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 56 - - -async def test_find_percentage_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 60 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 60 - - -async def test_find_in_range_defaults(hass: HomeAssistant) -> None: - """Test find in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(44) == 44 - assert mqtt_cover.find_in_range_from_percent(44, "cover") == 44 - - -async def test_find_in_range_altered(hass: HomeAssistant) -> None: - """Test find in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(40) == 120 - assert mqtt_cover.find_in_range_from_percent(40, "cover") == 120 - - -async def test_find_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(56) == 44 - assert mqtt_cover.find_in_range_from_percent(56, "cover") == 44 - - -async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(60) == 120 - assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 - - @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator From 0ae0331c5c8626bc6a14bd6109d046a860a47dab Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Dec 2023 12:23:01 +0100 Subject: [PATCH 581/927] Migrate google related tests to use freezegun (#105552) * Migrate google related tests to use freezegun * retrigger CI * Fix google tests * Add timezone to config_entry_token_expiry * Separate config_entry_token_expiry from token_expiry * Also test token refresh for offset-naive tokens * retrigger CI * Apply code review suggestion Co-authored-by: Allen Porter * Run ruff-format --------- Co-authored-by: Allen Porter --- tests/components/google/conftest.py | 5 +-- tests/components/google/test_calendar.py | 31 +++++++++---------- tests/components/google/test_config_flow.py | 3 +- tests/components/google/test_init.py | 6 +++- .../components/google_assistant/test_trait.py | 23 ++++++++------ tests/components/nest/test_camera.py | 3 +- 6 files changed, 40 insertions(+), 31 deletions(-) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 4196604b5d4..97d918c2e01 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator import datetime import http +import time from typing import Any, TypeVar from unittest.mock import Mock, mock_open, patch @@ -189,9 +190,9 @@ def creds( @pytest.fixture -def config_entry_token_expiry(token_expiry: datetime.datetime) -> float: +def config_entry_token_expiry() -> float: """Fixture for token expiration value stored in the config entry.""" - return token_expiry.timestamp() + return time.time() + 86400 @pytest.fixture diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8466f5ad4eb..d1cc41e166a 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -9,6 +9,7 @@ from unittest.mock import patch import urllib from aiohttp.client_exceptions import ClientError +from freezegun.api import FrozenDateTimeFactory from gcal_sync.auth import API_BASE_URL import pytest @@ -578,11 +579,13 @@ async def test_scan_calendar_error( async def test_future_event_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event = { @@ -600,12 +603,9 @@ async def test_future_event_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=60) - now_utc += datetime.timedelta(minutes=60) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has started state = hass.states.get(TEST_ENTITY) @@ -613,11 +613,13 @@ async def test_future_event_update_behavior( async def test_future_event_offset_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event_summary = "Test Event in Progress" @@ -638,12 +640,9 @@ async def test_future_event_offset_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=45) - now_utc += datetime.timedelta(minutes=45) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has not started, but the offset was reached state = hass.states.get(TEST_ENTITY) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index f534f624bf6..b2c472757b6 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError +from freezegun import freeze_time from oauth2client.client import ( DeviceFlowInfo, FlowExchangeError, @@ -130,7 +131,7 @@ async def primary_calendar( async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 9ede0573922..26a5cb2e192 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -699,7 +699,11 @@ async def test_add_event_location( @pytest.mark.parametrize( "config_entry_token_expiry", - [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], + [ + (datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1), + (utcnow().replace(tzinfo=None).timestamp()), + ], + ids=["max_timestamp", "timestamp_naive"], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 293b16e637a..30a83e7e0c3 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from unittest.mock import ANY, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -76,7 +77,7 @@ from homeassistant.core import ( HomeAssistant, State, ) -from homeassistant.util import color +from homeassistant.util import color, dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -3389,7 +3390,9 @@ async def test_humidity_setting_sensor_data( assert err.value.code == const.ERR_NOT_SUPPORTED -async def test_transport_control(hass: HomeAssistant) -> None: +async def test_transport_control( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the TransportControlTrait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -3398,7 +3401,7 @@ async def test_transport_control(hass: HomeAssistant) -> None: media_player.DOMAIN, feature, None, None ) - now = datetime(2020, 1, 1) + now = datetime(2020, 1, 1, tzinfo=dt_util.UTC) trt = trait.TransportControlTrait( hass, @@ -3429,13 +3432,13 @@ async def test_transport_control(hass: HomeAssistant) -> None: ) # Patch to avoid time ticking over during the command failing the test - with patch("homeassistant.util.dt.utcnow", return_value=now): - await trt.execute( - trait.COMMAND_MEDIA_SEEK_RELATIVE, - BASIC_DATA, - {"relativePositionMs": 10000}, - {}, - ) + freezer.move_to(now) + await trt.execute( + trait.COMMAND_MEDIA_SEEK_RELATIVE, + BASIC_DATA, + {"relativePositionMs": 10000}, + {}, + ) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: "media_player.bla", diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 56c5bedaf0d..647a3419501 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -8,6 +8,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch import aiohttp +from freezegun import freeze_time from google_nest_sdm.event import EventMessage import pytest @@ -173,7 +174,7 @@ async def async_get_image(hass, width=None, height=None): async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() From f263da843a47a2f978a119fb2ae8fec695e83e10 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Dec 2023 12:32:24 +0100 Subject: [PATCH 582/927] Fix typo's en and improve language on MQTT data descriptions (#106165) * Fix typo's en and prove language on MQTT data descriptions * Update homeassistant/components/mqtt/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/mqtt/strings.json * Update homeassistant/components/mqtt/strings.json Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/components/mqtt/strings.json | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 82b6a50df31..fac2f32d284 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -64,11 +64,11 @@ "client_key": "The private key file that belongs to your client certificate.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", - "set_ca_cert": "Select `Auto` for automatic CA validation or `Custom` and click `next` to set a custom CA certificate to allow validating your MQTT brokers certificate.", - "set_client_cert": "Enable and click `next` to set a client certifificate and private ket to authenticate against your MQTT broker.", - "transport": "The transport to use for the connextion to your MQTT broker.", + "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and click `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and click `next` to set a client certifificate and private key to authenticate against your MQTT broker.", + "transport": "The transport to be used for the connection to your MQTT broker.", "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", - "ws_path": "The WebSocket path to use for the connection to your MQTT broker." + "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." } }, "hassio_confirm": { @@ -183,17 +183,17 @@ }, "data_description": { "discovery": "Option to enable MQTT automatic discovery.", - "discovery_prefix": "The prefix of configuration topics the MQTT interation will subscribe to.", + "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", - "birth_topic": "The MQTT topic where Home Assistant will publish a birth message.", - "birth_payload": "The birth message that is published when MQTT is ready and connected.", - "birth_qos": "The quality of service of the birth message that is published when MQTT is ready and connected", - "birth_retain": "When set, Home Assistant will retain the birth message published to your MQTT broker.", - "will_enable": "When set, Home Assistant will ask your broker to publish a will message when MQTT is stopped or looses the connection to your broker.", - "will_topic": "The MQTT topic your MQTT broker will publish a will message to.", - "will_payload": "The message your MQTT broker will publish when the connection is lost.", - "will_qos": "The quality of service of the will message that is published by your MQTT broker.", - "will_retain": "When set, your MQTT broker will retain the will message." + "birth_topic": "The MQTT topic where Home Assistant will publish a `birth` message.", + "birth_payload": "The `birth` message that is published when MQTT is ready and connected.", + "birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected", + "birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.", + "will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.", + "will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.", + "will_qos": "The quality of service of the `will` message that is published by your MQTT broker.", + "will_retain": "When set, your MQTT broker will retain the `will` message." } } }, From 13908cf5a61df7d5029498073adc455d135ab25a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 21 Dec 2023 21:43:11 +1000 Subject: [PATCH 583/927] Add update platform to Tessie (#106093) * Add version platform * Add strings and test * Remove potential for None * Dont show progress when no update is running * Return None for no update * Add comment * Remove future feature * WIP * Fix statuses * update fixture * Remove entity name * Remove name correctly * Use False for in_progress --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/const.py | 10 +++ homeassistant/components/tessie/update.py | 64 +++++++++++++++++++ .../components/tessie/fixtures/vehicles.json | 8 +-- tests/components/tessie/test_update.py | 17 +++++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/tessie/update.py create mode 100644 tests/components/tessie/test_update.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index d34a1335ebc..63562faeb60 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 43f9b2c719b..a6ff7932fa4 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -36,3 +36,13 @@ class TessieClimateKeeper(StrEnum): ON = "on" DOG = "dog" CAMP = "camp" + + +class TessieUpdateStatus(StrEnum): + """Tessie Update Statuses.""" + + AVAILABLE = "available" + DOWNLOADING = "downloading" + INSTALLING = "installing" + WIFI_WAIT = "downloading_wifi_wait" + SCHEDULED = "scheduled" diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py new file mode 100644 index 00000000000..88899b61320 --- /dev/null +++ b/homeassistant/components/tessie/update.py @@ -0,0 +1,64 @@ +"""Update platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieUpdateStatus +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Update platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieUpdateEntity(coordinator) for coordinator in coordinators) + + +class TessieUpdateEntity(TessieEntity, UpdateEntity): + """Tessie Updates entity.""" + + _attr_supported_features = UpdateEntityFeature.PROGRESS + _attr_name = None + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the Update.""" + super().__init__(coordinator, "update") + + @property + def installed_version(self) -> str: + """Return the current app version.""" + # Discard build from version number + return self.coordinator.data["vehicle_state_car_version"].split(" ")[0] + + @property + def latest_version(self) -> str | None: + """Return the latest version.""" + # Dont show an update when its not in a state that can be actioned + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + TessieUpdateStatus.INSTALLING, + TessieUpdateStatus.DOWNLOADING, + TessieUpdateStatus.WIFI_WAIT, + ): + return self.get("vehicle_state_software_update_version") + return None + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if ( + self.get("vehicle_state_software_update_status") + == TessieUpdateStatus.INSTALLING + ): + return self.get("vehicle_state_software_update_install_perc") + return False diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index 9cc833a1cb2..05b19261c36 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -246,11 +246,11 @@ "service_mode": false, "service_mode_plus": false, "software_update": { - "download_perc": 0, + "download_perc": 100, "expected_duration_sec": 2700, - "install_perc": 1, - "status": "", - "version": " " + "install_perc": 10, + "status": "installing", + "version": "2023.44.30.4" }, "speed_limit_mode": { "active": false, diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py new file mode 100644 index 00000000000..b683f80116d --- /dev/null +++ b/tests/components/tessie/test_update.py @@ -0,0 +1,17 @@ +"""Test the Tessie update platform.""" +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +async def test_updates(hass: HomeAssistant) -> None: + """Tests that the updates are correct.""" + + assert len(hass.states.async_all("update")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("update")) == 1 + + assert hass.states.get("update.test").state == STATE_ON From 2b65fb22d3701925bbc07b1a46c30397efd967f5 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Thu, 21 Dec 2023 13:12:25 +0100 Subject: [PATCH 584/927] Remove warning message on BMW initialization (#106169) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/coordinator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 2634c6069c9..4e811d48647 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -32,8 +32,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - # Force metric system as BMW API apparently only returns metric values now - use_metric_units=True, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry From aa9f00099df4e400b7b3c9b19f916231a31cbe6b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 21 Dec 2023 12:22:42 +0000 Subject: [PATCH 585/927] Make evohome strictly typed (#106012) * initial commit * return to conventional approach * add type hint for wrapper * use walrus operator --- .strict-typing | 1 + homeassistant/components/evohome/__init__.py | 117 ++++++++++-------- homeassistant/components/evohome/climate.py | 41 ++++-- .../components/evohome/water_heater.py | 31 +++-- mypy.ini | 10 ++ 5 files changed, 130 insertions(+), 70 deletions(-) diff --git a/.strict-typing b/.strict-typing index 7c2d9d8daf2..01b88ec2781 100644 --- a/.strict-typing +++ b/.strict-typing @@ -126,6 +126,7 @@ homeassistant.components.energy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.evohome.* homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index fecfc2c0ef8..06712a83b6a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -4,15 +4,16 @@ Such systems include evohome, Round Thermostat, and others. """ from __future__ import annotations -from datetime import datetime as dt, timedelta +from collections.abc import Awaitable +from datetime import datetime, timedelta from http import HTTPStatus import logging import re from typing import Any -import evohomeasync +import evohomeasync as ev1 from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP -import evohomeasync2 +import evohomeasync2 as evo from evohomeasync2.schema.const import ( SZ_ALLOWED_SYSTEM_MODES, SZ_AUTO_WITH_RESET, @@ -112,15 +113,15 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( # system mode schemas are built dynamically, below -def _dt_local_to_aware(dt_naive: dt) -> dt: - dt_aware = dt_util.now() + (dt_naive - dt.now()) +def _dt_local_to_aware(dt_naive: datetime) -> datetime: + dt_aware = dt_util.now() + (dt_naive - datetime.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _dt_aware_to_naive(dt_aware: dt) -> dt: - dt_naive = dt.now() + (dt_aware - dt_util.now()) +def _dt_aware_to_naive(dt_aware: datetime) -> datetime: + dt_naive = datetime.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) @@ -157,12 +158,12 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: } -def _handle_exception(err) -> None: +def _handle_exception(err: evo.RequestFailed) -> None: """Return False if the exception can't be ignored.""" try: raise err - except evohomeasync2.AuthenticationFailed: + except evo.AuthenticationFailed: _LOGGER.error( ( "Failed to authenticate with the vendor's server. Check your username" @@ -173,7 +174,7 @@ def _handle_exception(err) -> None: err, ) - except evohomeasync2.RequestFailed: + except evo.RequestFailed: if err.status is None: _LOGGER.warning( ( @@ -206,7 +207,7 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict[str, str | dt], dict[str, str]]: + async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]: app_storage = await store.async_load() tokens = dict(app_storage or {}) @@ -227,16 +228,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) tokens, user_data = await load_auth_tokens(store) - client_v2 = evohomeasync2.EvohomeClient( + client_v2 = evo.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], - **tokens, # type: ignore[arg-type] + **tokens, session=async_get_clientsession(hass), ) try: await client_v2.login() - except evohomeasync2.AuthenticationFailed as err: + except evo.AuthenticationFailed as err: _handle_exception(err) return False finally: @@ -268,7 +269,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _config[GWS][0][TCS] = loc_config[GWS][0][TCS] _LOGGER.debug("Config = %s", _config) - client_v1 = evohomeasync.EvohomeClient( + client_v1 = ev1.EvohomeClient( client_v2.username, client_v2.password, session_id=user_data.get(SZ_SESSION_ID) if user_data else None, # STORAGE_VER 1 @@ -301,7 +302,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback -def setup_service_functions(hass: HomeAssistant, broker): +def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -401,7 +402,7 @@ def setup_service_functions(hass: HomeAssistant, broker): DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, - schema=vol.Any(*system_mode_schemas), + schema=vol.Schema(vol.Any(*system_mode_schemas)), ) # The zone modes are consistent across all systems and use the same schema @@ -425,8 +426,8 @@ class EvoBroker: def __init__( self, hass: HomeAssistant, - client: evohomeasync2.EvohomeClient, - client_v1: evohomeasync.EvohomeClient | None, + client: evo.EvohomeClient, + client_v1: ev1.EvohomeClient | None, store: Store[dict[str, Any]], params: ConfigType, ) -> None: @@ -438,11 +439,11 @@ class EvoBroker: self.params = params loc_idx = params[CONF_LOCATION_IDX] + self._location: evo.Location = client.locations[loc_idx] + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] - self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] - self.tcs_utc_offset = timedelta( - minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] - ) + self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] + self.tcs_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET]) self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: @@ -461,38 +462,46 @@ class EvoBroker: if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "sessionId": self.client_v1.broker.session_id, + SZ_SESSION_ID: self.client_v1.broker.session_id, } # this is the schema for STORAGE_VER == 1 else: app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) - async def call_client_api(self, api_function, update_state=True) -> Any: + async def call_client_api( + self, + api_function: Awaitable[dict[str, Any] | None], + update_state: bool = True, + ) -> dict[str, Any] | None: """Call a client API and update the broker state if required.""" try: result = await api_function - except evohomeasync2.EvohomeError as err: + except evo.RequestFailed as err: _handle_exception(err) - return + return None if update_state: # wait a moment for system to quiesce before updating state async_call_later(self.hass, 1, self._update_v2_api_state) return result - async def _update_v1_api_temps(self, *args, **kwargs) -> None: + async def _update_v1_api_temps(self) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 # mypy check + assert self.client_v1 is not None # mypy check - session_id = self.client_v1.broker.session_id # maybe receive a new session_id? + def get_session_id(client_v1: ev1.EvohomeClient) -> str | None: + user_data = client_v1.user_data if client_v1 else None + return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value] + + session_id = get_session_id(self.client_v1) self.temps = {} # these are now stale, will fall back to v2 temps try: temps = await self.client_v1.get_temperatures() - except evohomeasync.InvalidSchema as exc: + except ev1.InvalidSchema as err: _LOGGER.warning( ( "Unable to obtain high-precision temperatures. " @@ -500,11 +509,11 @@ class EvoBroker: "so the high-precision feature will be disabled until next restart." "Message is: %s" ), - exc, + err, ) self.client_v1 = None - except evohomeasync.EvohomeError as exc: + except ev1.RequestFailed as err: _LOGGER.warning( ( "Unable to obtain the latest high-precision temperatures. " @@ -512,14 +521,11 @@ class EvoBroker: "Proceeding without high-precision temperatures for now. " "Message is: %s" ), - exc, + err, ) else: - if ( - str(self.client_v1.location_id) - != self.client.locations[self.params[CONF_LOCATION_IDX]].locationId - ): + if str(self.client_v1.location_id) != self._location.locationId: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " @@ -535,15 +541,14 @@ class EvoBroker: _LOGGER.debug("Temperatures = %s", self.temps) - async def _update_v2_api_state(self, *args, **kwargs) -> None: + async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" access_token = self.client.access_token # maybe receive a new token? - loc_idx = self.params[CONF_LOCATION_IDX] try: - status = await self.client.locations[loc_idx].refresh_status() - except evohomeasync2.EvohomeError as err: + status = await self._location.refresh_status() + except evo.RequestFailed as err: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) @@ -553,7 +558,7 @@ class EvoBroker: if access_token != self.client.access_token: await self.save_auth_tokens() - async def async_update(self, *args, **kwargs) -> None: + async def async_update(self, *args: Any) -> None: """Get the latest state data of an entire Honeywell TCC Location. This includes state data for a Controller and all its child devices, such as the @@ -575,9 +580,11 @@ class EvoDevice(Entity): _attr_should_poll = False - _evo_id: str - - def __init__(self, evo_broker, evo_device) -> None: + def __init__( + self, + evo_broker: EvoBroker, + evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, + ) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device self._evo_broker = evo_broker @@ -629,9 +636,14 @@ class EvoChild(EvoDevice): This includes (up to 12) Heating Zones and (optionally) a DHW controller. """ - def __init__(self, evo_broker, evo_device) -> None: + _evo_id: str # mypy hint + + def __init__( + self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + ) -> None: """Initialize a evohome Controller (hub).""" super().__init__(evo_broker, evo_device) + self._schedule: dict[str, Any] = {} self._setpoints: dict[str, Any] = {} @@ -639,6 +651,8 @@ class EvoChild(EvoDevice): def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + if self._evo_broker.temps.get(self._evo_id) is not None: return self._evo_broker.temps[self._evo_id] return self._evo_device.temperature @@ -650,7 +664,7 @@ class EvoChild(EvoDevice): Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: + def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset return dt_util.as_local(dt_aware) @@ -686,7 +700,7 @@ class EvoChild(EvoDevice): switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day # mypy check + assert switchpoint_time_of_day is not None # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) @@ -708,7 +722,10 @@ class EvoChild(EvoDevice): async def _update_schedule(self) -> None: """Get the latest schedule, if any.""" - self._schedule = await self._evo_broker.call_client_api( + + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + + self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] self._evo_device.get_schedule(), update_state=False ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index ec518ea4a99..1e092d7fc34 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,10 +1,11 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" from __future__ import annotations -from datetime import datetime as dt +from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any +import evohomeasync2 as evo from evohomeasync2.schema.const import ( SZ_ACTIVE_FAULTS, SZ_ALLOWED_SYSTEM_MODES, @@ -61,6 +62,10 @@ from .const import ( EVO_TEMPOVER, ) +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW @@ -104,7 +109,7 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", @@ -163,16 +168,19 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.Zone # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: """Initialize a Honeywell TCC Zone.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.zoneId if evo_device.modelType.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.zoneId}z" else: self._attr_unique_id = evo_device.zoneId - self._evo_id = evo_device.zoneId self._attr_name = evo_device.name @@ -197,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity): temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: - duration = data[ATTR_DURATION_UNTIL] + duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) @@ -232,6 +240,8 @@ class EvoZone(EvoChild, EvoClimateEntity): """Return the current preset mode, e.g., home, away, temp.""" if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + if self._evo_device.mode is None: + return None return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property @@ -252,6 +262,9 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" + + assert self._evo_device.setpointStatus is not None # mypy check + temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: @@ -300,14 +313,15 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.target_heat_temperature - if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: # EVO_PERMOVER until = None + temperature = self._evo_device.target_heat_temperature + assert temperature is not None # mypy check + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( self._evo_device.set_temperature(temperature, until=until) @@ -334,12 +348,15 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.ControlSystem # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: """Initialize a Honeywell TCC Controller/Location.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.systemId self._attr_unique_id = evo_device.systemId - self._evo_id = evo_device.systemId self._attr_name = evo_device.location.name modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.config[SZ_ALLOWED_SYSTEM_MODES]] @@ -371,11 +388,11 @@ class EvoController(EvoClimateEntity): await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: dt | None = None) -> None: + async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_tcs.set_mode(mode, until=until) + self._evo_tcs.set_mode(mode, until=until) # type: ignore[arg-type] ) @property diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index b0e5c702787..77a7b1c2ced 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING, Any +import evohomeasync2 as evo from evohomeasync2.schema.const import ( SZ_ACTIVE_FAULTS, SZ_DHW_ID, @@ -31,6 +33,10 @@ import homeassistant.util.dt as dt_util from . import EvoChild from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" @@ -51,7 +57,9 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] + + assert broker.tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", @@ -72,12 +80,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.HotWater # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: """Initialize an evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.dhwId self._attr_unique_id = evo_device.dhwId - self._evo_id = evo_device.dhwId self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE @@ -87,15 +98,19 @@ class EvoDHW(EvoChild, WaterHeaterEntity): ) @property - def current_operation(self) -> str: + def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.state] + if (device_state := self._evo_device.state) is None: + return None + return EVO_STATE_TO_HA[device_state] @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" + if self._evo_device.state is None: + return None is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent @@ -129,11 +144,11 @@ class EvoDHW(EvoChild, WaterHeaterEntity): """Turn away mode off.""" await self._evo_broker.call_client_api(self._evo_device.reset_mode()) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self._evo_broker.call_client_api(self._evo_device.set_on()) - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self._evo_broker.call_client_api(self._evo_device.set_off()) diff --git a/mypy.ini b/mypy.ini index 45395463ce9..bd0e4f76b85 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1021,6 +1021,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.evohome.*] +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.faa_delays.*] check_untyped_defs = true disallow_incomplete_defs = true From f0104d6851e8d9370091510c4101d5c0ea64f3cb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Dec 2023 13:25:21 +0100 Subject: [PATCH 586/927] Remove gios sensor description required fields mixin (#106174) --- homeassistant/components/gios/sensor.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 9ca34b2e77c..99c1775beef 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -42,17 +42,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) -class GiosSensorRequiredKeysMixin: - """Class for GIOS entity required keys.""" - - value: Callable[[GiosSensors], StateType] - - -@dataclass(frozen=True) -class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): +@dataclass(frozen=True, kw_only=True) +class GiosSensorEntityDescription(SensorEntityDescription): """Class describing GIOS sensor entities.""" + value: Callable[[GiosSensors], StateType] subkey: str | None = None From e1f31194f7f411ae1a12ec628f550e1d2dcc0748 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 21 Dec 2023 13:39:02 +0100 Subject: [PATCH 587/927] Move cloud stt engine to config entry (#99608) * Migrate cloud stt to config entry * Update default engine * Test config flow * Migrate pipelines with cloud stt engine to new engine id * Fix test after rebase * Update and add comment * Remove cloud specifics from default stt engine * Refactor cloud assist pipeline * Fix cloud stt entity_id * Try to wait for platforms before creating default pipeline * Clean up import * Move function in cloud assist pipeline * Wait for tts platform loaded in stt migration * Update deprecation dates * Clean up not used fixture * Add test for async_update_pipeline * Define pipeline update interface better * Remove leftover * Fix tests * Change default engine test * Add test for missing stt entity during login * Add and update comments * Update config entry title --- .../components/assist_pipeline/__init__.py | 2 + .../components/assist_pipeline/pipeline.py | 43 ++++ homeassistant/components/cloud/__init__.py | 39 +++- .../components/cloud/assist_pipeline.py | 51 ++++- homeassistant/components/cloud/config_flow.py | 23 ++ homeassistant/components/cloud/const.py | 3 + homeassistant/components/cloud/http_api.py | 5 +- homeassistant/components/cloud/strings.json | 6 + homeassistant/components/cloud/stt.py | 33 +-- homeassistant/components/stt/legacy.py | 3 - .../assist_pipeline/test_pipeline.py | 131 ++++++++++++ tests/components/cloud/__init__.py | 5 +- tests/components/cloud/test_config_flow.py | 40 ++++ tests/components/cloud/test_http_api.py | 53 ++++- tests/components/cloud/test_stt.py | 201 ++++++++++++++++++ tests/components/stt/test_init.py | 73 ++++--- 16 files changed, 650 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/cloud/config_flow.py create mode 100644 tests/components/cloud/test_config_flow.py create mode 100644 tests/components/cloud/test_stt.py diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 6d00f26ee15..7f6bef6e3c0 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -31,6 +31,7 @@ from .pipeline import ( async_get_pipeline, async_get_pipelines, async_setup_pipeline_store, + async_update_pipeline, ) from .websocket_api import async_register_websocket_api @@ -40,6 +41,7 @@ __all__ = ( "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "async_update_pipeline", "AudioSettings", "Pipeline", "PipelineEvent", diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 2ee1c71ccb8..71136dcdecb 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -43,6 +43,7 @@ from homeassistant.helpers.collection import ( ) from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import ( dt as dt_util, language as language_util, @@ -276,6 +277,48 @@ def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]: return pipeline_data.pipeline_store.data.values() +async def async_update_pipeline( + hass: HomeAssistant, + pipeline: Pipeline, + *, + conversation_engine: str | UndefinedType = UNDEFINED, + conversation_language: str | UndefinedType = UNDEFINED, + language: str | UndefinedType = UNDEFINED, + name: str | UndefinedType = UNDEFINED, + stt_engine: str | None | UndefinedType = UNDEFINED, + stt_language: str | None | UndefinedType = UNDEFINED, + tts_engine: str | None | UndefinedType = UNDEFINED, + tts_language: str | None | UndefinedType = UNDEFINED, + tts_voice: str | None | UndefinedType = UNDEFINED, + wake_word_entity: str | None | UndefinedType = UNDEFINED, + wake_word_id: str | None | UndefinedType = UNDEFINED, +) -> None: + """Update a pipeline.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + updates: dict[str, Any] = pipeline.to_json() + updates.pop("id") + # Refactor this once we bump to Python 3.12 + # and have https://peps.python.org/pep-0692/ + for key, val in ( + ("conversation_engine", conversation_engine), + ("conversation_language", conversation_language), + ("language", language), + ("name", name), + ("stt_engine", stt_engine), + ("stt_language", stt_language), + ("tts_engine", tts_engine), + ("tts_language", tts_language), + ("tts_voice", tts_voice), + ("wake_word_entity", wake_word_entity), + ("wake_word_id", wake_word_id), + ): + if val is not UNDEFINED: + updates[key] = val + + await pipeline_data.pipeline_store.async_update_item(pipeline.id, updates) + + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4dc242376d9..bf60ab9cc94 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -10,6 +10,7 @@ from hass_nabucasa import Cloud import voluptuous as vol from homeassistant.components import alexa, google_assistant +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DESCRIPTION, CONF_MODE, @@ -51,6 +52,7 @@ from .const import ( CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, + DATA_PLATFORMS_SETUP, DOMAIN, MODE_DEV, MODE_PROD, @@ -61,6 +63,8 @@ from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD +PLATFORMS = [Platform.STT] + SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" @@ -262,6 +266,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_manage_legacy_subscription_issue(hass, subscription_info) loaded = False + stt_platform_loaded = asyncio.Event() + tts_platform_loaded = asyncio.Event() + hass.data[DATA_PLATFORMS_SETUP] = { + Platform.STT: stt_platform_loaded, + Platform.TTS: tts_platform_loaded, + } async def _on_start() -> None: """Discover platforms.""" @@ -272,15 +282,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - stt_platform_loaded = asyncio.Event() - tts_platform_loaded = asyncio.Event() - stt_info = {"platform_loaded": stt_platform_loaded} tts_info = {"platform_loaded": tts_platform_loaded} await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) - await async_load_platform(hass, Platform.STT, DOMAIN, stt_info, config) await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) - await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) + await tts_platform_loaded.wait() + + # The config entry should be loaded after the legacy tts platform is loaded + # to make sure that the tts integration is setup before we try to migrate + # old assist pipelines in the cloud stt entity. + await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) async def _on_connect() -> None: """Handle cloud connect.""" @@ -304,7 +315,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cloud.register_on_initialized(_on_initialized) await cloud.initialize() - await http_api.async_setup(hass) + http_api.async_setup(hass) account_link.async_setup(hass) @@ -340,3 +351,19 @@ def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: await cloud.remote.disconnect() cloud.client.prefs.async_listen_updates(remote_prefs_updated) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] + stt_platform_loaded.set() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + return unload_ok diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index 8054b3bd953..31e990cdb81 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -1,31 +1,48 @@ """Handle Cloud assist pipelines.""" +import asyncio + from homeassistant.components.assist_pipeline import ( async_create_default_pipeline, async_get_pipelines, async_setup_pipeline_store, + async_update_pipeline, ) from homeassistant.components.conversation import HOME_ASSISTANT_AGENT +from homeassistant.components.stt import DOMAIN as STT_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .const import DOMAIN +from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: """Create a cloud assist pipeline.""" + # Wait for stt and tts platforms to set up before creating the pipeline. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await asyncio.gather(*(event.wait() for event in platforms_setup.values())) # Make sure the pipeline store is loaded, needed because assist_pipeline # is an after dependency of cloud await async_setup_pipeline_store(hass) + entity_registry = er.async_get(hass) + new_stt_engine_id = entity_registry.async_get_entity_id( + STT_DOMAIN, DOMAIN, STT_ENTITY_UNIQUE_ID + ) + if new_stt_engine_id is None: + # If there's no cloud stt entity, we can't create a cloud pipeline. + return None + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: """Return the ID of a cloud-enabled assist pipeline or None. - Check if a cloud pipeline already exists with - legacy cloud engine id. + Check if a cloud pipeline already exists with either + legacy or current cloud engine ids. """ for pipeline in async_get_pipelines(hass): if ( pipeline.conversation_engine == HOME_ASSISTANT_AGENT - and pipeline.stt_engine == DOMAIN + and pipeline.stt_engine in (DOMAIN, new_stt_engine_id) and pipeline.tts_engine == DOMAIN ): return pipeline.id @@ -34,7 +51,7 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: if (cloud_assist_pipeline(hass)) is not None or ( cloud_pipeline := await async_create_default_pipeline( hass, - stt_engine_id=DOMAIN, + stt_engine_id=new_stt_engine_id, tts_engine_id=DOMAIN, pipeline_name="Home Assistant Cloud", ) @@ -42,3 +59,27 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: return None return cloud_pipeline.id + + +async def async_migrate_cloud_pipeline_stt_engine( + hass: HomeAssistant, stt_engine_id: str +) -> None: + """Migrate the speech-to-text engine in the cloud assist pipeline.""" + # Migrate existing pipelines with cloud stt to use new cloud stt engine id. + # Added in 2024.01.0. Can be removed in 2025.01.0. + + # We need to make sure that tts is loaded before this migration. + # Assist pipeline will call default engine of tts when setting up the store. + # Wait for the tts platform loaded event here. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await platforms_setup[Platform.TTS].wait() + + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + pipelines = async_get_pipelines(hass) + for pipeline in pipelines: + if pipeline.stt_engine != DOMAIN: + continue + await async_update_pipeline(hass, pipeline, stt_engine=stt_engine_id) diff --git a/homeassistant/components/cloud/config_flow.py b/homeassistant/components/cloud/config_flow.py new file mode 100644 index 00000000000..a9554d97294 --- /dev/null +++ b/homeassistant/components/cloud/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for the Cloud integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class CloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the Cloud integration.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the system step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="Home Assistant Cloud", data={}) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 6e20978ec8d..db964607923 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,5 +1,6 @@ """Constants for the cloud component.""" DOMAIN = "cloud" +DATA_PLATFORMS_SETUP = "cloud_platforms_setup" REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = "alexa_enabled" @@ -64,3 +65,5 @@ MODE_DEV = "development" MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" + +STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index d01b0c29e06..849a1c99db9 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -28,7 +28,7 @@ from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info @@ -66,7 +66,8 @@ _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { } -async def async_setup(hass: HomeAssistant) -> None: +@callback +def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 8195b78a01e..56fb3c0f5c9 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -1,4 +1,10 @@ { + "config": { + "step": {}, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "system_health": { "info": { "can_reach_cert_server": "Reach Certificate Server", diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 7b6da8b7403..b652a36fa8a 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -13,37 +13,38 @@ from homeassistant.components.stt import ( AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .assist_pipeline import async_migrate_cloud_pipeline_stt_engine from .client import CloudClient -from .const import DOMAIN +from .const import DOMAIN, STT_ENTITY_UNIQUE_ID _LOGGER = logging.getLogger(__name__) -async def async_get_engine( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> CloudProvider: - """Set up Cloud speech component.""" + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Home Assistant Cloud speech platform via config entry.""" cloud: Cloud[CloudClient] = hass.data[DOMAIN] - - cloud_provider = CloudProvider(cloud) - if discovery_info is not None: - discovery_info["platform_loaded"].set() - return cloud_provider + async_add_entities([CloudProviderEntity(cloud)]) -class CloudProvider(Provider): +class CloudProviderEntity(SpeechToTextEntity): """NabuCasa speech API provider.""" + _attr_name = "Home Assistant Cloud" + _attr_unique_id = STT_ENTITY_UNIQUE_ID + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud @@ -78,6 +79,10 @@ class CloudProvider(Provider): """Return a list of supported channels.""" return [AudioChannels.CHANNEL_MONO] + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await async_migrate_cloud_pipeline_stt_engine(self.hass, self.entity_id) + async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index cd5aef312ce..45f8ccefc68 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -29,9 +29,6 @@ _LOGGER = logging.getLogger(__name__) @callback def async_default_provider(hass: HomeAssistant) -> str | None: """Return the domain of the default provider.""" - if "cloud" in hass.data[DATA_PROVIDERS]: - return "cloud" - return next(iter(hass.data[DATA_PROVIDERS]), None) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 597d355806f..35913df7400 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,4 +1,5 @@ """Websocket tests for Voice Assistant integration.""" +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import ANY, patch @@ -16,6 +17,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -26,6 +28,13 @@ from .conftest import MockSttProvider, MockTTSProvider from tests.common import flush_store +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield + + @pytest.fixture(autouse=True) async def load_homeassistant(hass) -> None: """Load the homeassistant integration.""" @@ -478,3 +487,125 @@ async def test_default_pipeline_unsupported_tts_language( wake_word_entity=None, wake_word_id=None, ) + + +async def test_update_pipeline( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test async_update_pipeline.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=ANY, + language="en", + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + ] + + pipeline = pipelines[0] + await async_update_pipeline( + hass, + pipeline, + conversation_engine="homeassistant_1", + conversation_language="de", + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + pipeline = pipelines[0] + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_1", + "stt_language": "de", + "tts_engine": "test_1", + "tts_language": "de", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } + + await async_update_pipeline( + hass, + pipeline, + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", + ) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_2", + "stt_language": "en", + "tts_engine": "test_2", + "tts_language": "en", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index ea8c09706c5..22b84f032f6 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1,7 +1,8 @@ """Tests for the cloud component.""" - from unittest.mock import AsyncMock, patch +from hass_nabucasa import Cloud + from homeassistant.components import cloud from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -14,7 +15,7 @@ async def mock_cloud(hass, config=None): assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) - cloud_inst = hass.data["cloud"] + cloud_inst: Cloud = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): await cloud_inst.initialize() diff --git a/tests/components/cloud/test_config_flow.py b/tests/components/cloud/test_config_flow.py new file mode 100644 index 00000000000..ee4e37276dc --- /dev/null +++ b/tests/components/cloud/test_config_flow.py @@ -0,0 +1,40 @@ +"""Test the Home Assistant Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.cloud.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test create cloud entry.""" + + with patch( + "homeassistant.components.cloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "create_entry" + assert result["title"] == "Home Assistant Cloud" + assert result["data"] == {} + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_entries(hass: HomeAssistant) -> None: + """Test creating multiple cloud entries.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 3d7e6a69e3c..29930632691 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -46,6 +46,26 @@ PIPELINE_DATA_LEGACY = { "preferred_item": "12345", } +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "stt.home_assistant_cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + PIPELINE_DATA_OTHER = { "items": [ { @@ -127,7 +147,34 @@ async def test_google_actions_sync_fails( assert mock_request_sync.call_count == 1 -@pytest.mark.parametrize("pipeline_data", [PIPELINE_DATA_LEGACY]) +async def test_login_view_missing_stt_entity( + hass: HomeAssistant, + setup_cloud: None, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when the cloud stt entity is missing.""" + # Make sure that the cloud stt entity does not exist. + entity_registry.async_remove("stt.home_assistant_cloud") + await hass.async_block_till_done() + + cloud_client = await hass_client() + + # We assume the user needs to login again for some reason. + with patch( + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": None} + create_pipeline_mock.assert_not_awaited() + + +@pytest.mark.parametrize("pipeline_data", [PIPELINE_DATA, PIPELINE_DATA_LEGACY]) async def test_login_view_existing_pipeline( hass: HomeAssistant, cloud: MagicMock, @@ -195,7 +242,7 @@ async def test_login_view_create_pipeline( assert result == {"success": True, "cloud_pipeline": "12345"} create_pipeline_mock.assert_awaited_once_with( hass, - stt_engine_id="cloud", + stt_engine_id="stt.home_assistant_cloud", tts_engine_id="cloud", pipeline_name="Home Assistant Cloud", ) @@ -234,7 +281,7 @@ async def test_login_view_create_pipeline_fail( assert result == {"success": True, "cloud_pipeline": None} create_pipeline_mock.assert_awaited_once_with( hass, - stt_engine_id="cloud", + stt_engine_id="stt.home_assistant_cloud", tts_engine_id="cloud", pipeline_name="Home Assistant Cloud", ) diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py new file mode 100644 index 00000000000..666d8ae7d65 --- /dev/null +++ b/tests/components/cloud/test_stt.py @@ -0,0 +1,201 @@ +"""Test the speech-to-text platform for the cloud integration.""" +from collections.abc import AsyncGenerator +from copy import deepcopy +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from hass_nabucasa.voice import STTResponse, VoiceError +import pytest + +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", +} + + +@pytest.fixture(autouse=True) +async def load_homeassistant(hass: HomeAssistant) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield + + +@pytest.mark.parametrize( + ("mock_process_stt", "expected_response_data"), + [ + ( + AsyncMock(return_value=STTResponse(True, "Turn the Kitchen Lights on")), + {"text": "Turn the Kitchen Lights on", "result": "success"}, + ), + (AsyncMock(side_effect=VoiceError("Boom!")), {"text": None, "result": "error"}), + ], +) +async def test_cloud_speech( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + mock_process_stt: AsyncMock, + expected_response_data: dict[str, Any], +) -> None: + """Test cloud text-to-speech.""" + cloud.voice.process_stt = mock_process_stt + + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + client = await hass_client() + + response = await client.post( + "/api/stt/stt.home_assistant_cloud", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" + " language=de-DE" + ) + }, + data=b"Test", + ) + response_data = await response.json() + + assert mock_process_stt.call_count == 1 + assert ( + mock_process_stt.call_args.kwargs["content_type"] + == "audio/wav; codecs=audio/pcm; samplerate=16000" + ) + assert mock_process_stt.call_args.kwargs["language"] == "de-DE" + assert response.status == HTTPStatus.OK + assert response_data == expected_response_data + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +async def test_migrating_pipelines( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test migrating pipelines when cloud stt entity is added.""" + cloud.voice.process_stt = AsyncMock( + return_value=STTResponse(True, "Turn the Kitchen Lights on") + ) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": deepcopy(PIPELINE_DATA), + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + await hass.async_block_till_done() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + # The stt engine should be updated to the new cloud stt engine id. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] + == "stt.home_assistant_cloud" + ) + + # The other items should stay the same. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_engine"] + == "conversation_engine_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_language"] + == "language_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["id"] + == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["name"] == "Home Assistant Cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_language"] == "language_1" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == "cloud" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_voice"] + == "Arnold Schwarzenegger" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_entity"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_id"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][1] == PIPELINE_DATA["items"][1] + assert hass_storage[STORAGE_KEY]["data"]["items"][2] == PIPELINE_DATA["items"][2] diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 4100df94b9e..9764451c5d5 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -121,12 +121,20 @@ class STTFlow(ConfigFlow): """Test flow.""" -@pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: - """Mock config flow.""" - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") +@pytest.fixture(name="config_flow_test_domain") +def config_flow_test_domain_fixture() -> str: + """Test domain fixture.""" + return TEST_DOMAIN - with mock_config_flow(TEST_DOMAIN, STTFlow): + +@pytest.fixture(autouse=True) +def config_flow_fixture( + hass: HomeAssistant, config_flow_test_domain: str +) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{config_flow_test_domain}.config_flow") + + with mock_config_flow(config_flow_test_domain, STTFlow): yield @@ -137,6 +145,7 @@ async def setup_fixture( request: pytest.FixtureRequest, ) -> MockProvider | MockProviderEntity: """Set up the test environment.""" + provider: MockProvider | MockProviderEntity if request.param == "mock_setup": provider = MockProvider() await mock_setup(hass, tmp_path, provider) @@ -166,7 +175,10 @@ async def mock_setup( async def mock_config_entry_setup( - hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + test_domain: str = TEST_DOMAIN, ) -> MockConfigEntry: """Set up a test provider via config entry.""" @@ -187,7 +199,7 @@ async def mock_config_entry_setup( mock_integration( hass, MockModule( - TEST_DOMAIN, + test_domain, async_setup_entry=async_setup_entry_init, async_unload_entry=async_unload_entry_init, ), @@ -201,9 +213,9 @@ async def mock_config_entry_setup( """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) - mock_stt_entity_platform(hass, tmp_path, TEST_DOMAIN, async_setup_entry_platform) + mock_stt_entity_platform(hass, tmp_path, test_domain, async_setup_entry_platform) - config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry = MockConfigEntry(domain=test_domain) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -456,7 +468,11 @@ async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: assert async_default_engine(hass) is None -async def test_default_engine(hass: HomeAssistant, tmp_path: Path) -> None: +async def test_default_engine( + hass: HomeAssistant, + tmp_path: Path, + mock_provider: MockProvider, +) -> None: """Test async_default_engine.""" mock_stt_platform( hass, @@ -479,26 +495,31 @@ async def test_default_engine_entity( assert async_default_engine(hass) == f"{DOMAIN}.{TEST_DOMAIN}" -async def test_default_engine_prefer_cloud(hass: HomeAssistant, tmp_path: Path) -> None: +@pytest.mark.parametrize("config_flow_test_domain", ["new_test"]) +async def test_default_engine_prefer_provider( + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + mock_provider: MockProvider, + config_flow_test_domain: str, +) -> None: """Test async_default_engine.""" - mock_stt_platform( - hass, - tmp_path, - TEST_DOMAIN, - async_get_engine=AsyncMock(return_value=mock_provider), - ) - mock_stt_platform( - hass, - tmp_path, - "cloud", - async_get_engine=AsyncMock(return_value=mock_provider), - ) - assert await async_setup_component( - hass, "stt", {"stt": [{"platform": TEST_DOMAIN}, {"platform": "cloud"}]} + mock_provider_entity.url_path = "stt.new_test" + mock_provider_entity._attr_name = "New test" + + await mock_setup(hass, tmp_path, mock_provider) + await mock_config_entry_setup( + hass, tmp_path, mock_provider_entity, test_domain=config_flow_test_domain ) await hass.async_block_till_done() - assert async_default_engine(hass) == "cloud" + entity_engine = async_get_speech_to_text_engine(hass, "stt.new_test") + assert entity_engine is not None + assert entity_engine.name == "New test" + provider_engine = async_get_speech_to_text_engine(hass, "test") + assert provider_engine is not None + assert provider_engine.name == "test" + assert async_default_engine(hass) == "test" async def test_get_engine_legacy( From ab2f3381a51a6fa01b0ca48c4a48ea6d1391adc7 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Dec 2023 13:58:01 +0000 Subject: [PATCH 588/927] Bump ring-doorbell to 0.8.5 (#106178) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 36514fc8f35..85cab6f1763 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.3"] + "requirements": ["ring-doorbell[listen]==0.8.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7dbca06daf..7d556fa721b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2368,7 +2368,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.3 +ring-doorbell[listen]==0.8.5 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51d0bac7663..0c8f9d72e42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1780,7 +1780,7 @@ reolink-aio==0.8.4 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.3 +ring-doorbell[listen]==0.8.5 # homeassistant.components.roku rokuecp==0.18.1 From 39a956cea3d2716070c2b2ff3c27634593ae1839 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Thu, 21 Dec 2023 15:00:11 +0100 Subject: [PATCH 589/927] Bump Devialet to 1.4.4 (#106171) Bump Devialet==1.4.4 --- homeassistant/components/devialet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json index 286b9bfb112..e09485a8599 100644 --- a/homeassistant/components/devialet/manifest.json +++ b/homeassistant/components/devialet/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/devialet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["devialet==1.4.3"], + "requirements": ["devialet==1.4.4"], "zeroconf": ["_devialet-http._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d556fa721b..46cd06c365e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ demetriek==0.4.0 denonavr==0.11.4 # homeassistant.components.devialet -devialet==1.4.3 +devialet==1.4.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c8f9d72e42..e65c3a093c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -561,7 +561,7 @@ demetriek==0.4.0 denonavr==0.11.4 # homeassistant.components.devialet -devialet==1.4.3 +devialet==1.4.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 From 0534b0dee4136bf0ce973a70cdaa6868e8b39e03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Dec 2023 15:32:25 +0100 Subject: [PATCH 590/927] Improve entity tests (#106175) --- tests/helpers/test_entity.py | 57 +++++++++++++++--------------------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 911f41c0766..c3021e397ee 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -31,7 +31,6 @@ from tests.common import ( MockEntityPlatform, MockModule, MockPlatform, - get_test_home_assistant, mock_integration, mock_registry, ) @@ -61,6 +60,17 @@ def test_generate_entity_id_given_keys() -> None: ) +async def test_generate_entity_id_given_hass(hass: HomeAssistant) -> None: + """Test generating an entity id given hass object.""" + hass.states.async_set("test.overwrite_hidden_true", "test") + + fmt = "test.{}" + assert ( + entity.generate_entity_id(fmt, "overwrite hidden true", hass=hass) + == "test.overwrite_hidden_true_2" + ) + + async def test_async_update_support(hass: HomeAssistant) -> None: """Test async update getting called.""" sync_update = [] @@ -95,40 +105,19 @@ async def test_async_update_support(hass: HomeAssistant) -> None: assert len(async_update) == 1 -class TestHelpersEntity: - """Test homeassistant.helpers.entity module.""" +async def test_device_class(hass: HomeAssistant) -> None: + """Test device class attribute.""" + ent = entity.Entity() + ent.entity_id = "test.overwrite_hidden_true" + ent.hass = hass + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) is None - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.entity = entity.Entity() - self.entity.entity_id = "test.overwrite_hidden_true" - self.hass = self.entity.hass = get_test_home_assistant() - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_generate_entity_id_given_hass(self): - """Test generating an entity id given hass object.""" - fmt = "test.{}" - assert ( - entity.generate_entity_id(fmt, "overwrite hidden true", hass=self.hass) - == "test.overwrite_hidden_true_2" - ) - - def test_device_class(self): - """Test device class attribute.""" - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - with patch( - "homeassistant.helpers.entity.Entity.device_class", new="test_class" - ): - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" + ent._attr_device_class = "test_class" + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" async def test_warn_slow_update( From d5c7ae5b340b8d15ba56426e3f68150434bc89bd Mon Sep 17 00:00:00 2001 From: Robert Groot <8398505+iamrgroot@users.noreply.github.com> Date: Thu, 21 Dec 2023 15:39:51 +0100 Subject: [PATCH 591/927] Add Energyzero get_gas_prices and get_energy_price services (#101374) Co-authored-by: Klaas Schoute Co-authored-by: Joost Lekkerkerker --- .../components/energyzero/__init__.py | 4 + .../components/energyzero/services.py | 129 + .../components/energyzero/services.yaml | 34 + .../components/energyzero/strings.json | 43 + .../energyzero/snapshots/test_services.ambr | 2401 +++++++++++++++++ tests/components/energyzero/test_services.py | 90 + 6 files changed, 2701 insertions(+) create mode 100644 homeassistant/components/energyzero/services.py create mode 100644 homeassistant/components/energyzero/services.yaml create mode 100644 tests/components/energyzero/snapshots/test_services.ambr create mode 100644 tests/components/energyzero/test_services.py diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 096e312efc0..0eac874f1ed 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator +from .services import async_register_services PLATFORMS = [Platform.SENSOR] @@ -25,6 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async_register_services(hass, coordinator) + return True diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py new file mode 100644 index 00000000000..fb451c40401 --- /dev/null +++ b/homeassistant/components/energyzero/services.py @@ -0,0 +1,129 @@ +"""The EnergyZero services.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from energyzero import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EnergyZeroDataUpdateCoordinator + +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_SERVICE_NAME: Final = "get_energy_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +class PriceType(Enum): + """Type of price.""" + + ENERGY = "energy" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + "Invalid datetime provided.", + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: + """Serialize prices.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices.timestamp_prices + ] + } + + +async def __get_prices( + call: ServiceCall, + *, + coordinator: EnergyZeroDataUpdateCoordinator, + price_type: PriceType, +) -> ServiceResponse: + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.energyzero.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + else: + data = await coordinator.energyzero.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + return __serialize_prices(data) + + +@callback +def async_register_services( + hass: HomeAssistant, coordinator: EnergyZeroDataUpdateCoordinator +): + """Set up EnergyZero services.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, coordinator=coordinator, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_SERVICE_NAME, + partial(__get_prices, coordinator=coordinator, price_type=PriceType.ENERGY), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml new file mode 100644 index 00000000000..1bcc5ae34be --- /dev/null +++ b/homeassistant/components/energyzero/services.yaml @@ -0,0 +1,34 @@ +get_gas_prices: + fields: + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: +get_energy_prices: + fields: + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index a27ce236c28..81f54f4222a 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -9,6 +9,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + } + }, "entity": { "sensor": { "current_hour_price": { @@ -39,5 +44,43 @@ "name": "Hours priced equal or lower than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from EnergyZero.", + "fields": { + "incl_vat": { + "name": "Including VAT", + "description": "Include VAT in the prices." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_prices": { + "name": "Get energy prices", + "description": "Request energy prices from EnergyZero.", + "fields": { + "incl_vat": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr new file mode 100644 index 00000000000..73d161477d0 --- /dev/null +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -0,0 +1,2401 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py new file mode 100644 index 00000000000..7939b06ce8e --- /dev/null +++ b/tests/components/energyzero/test_services.py @@ -0,0 +1,90 @@ +"""Tests for the services provided by the EnergyZero integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.components.energyzero.services import ( + ENERGY_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the EnergyZero Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +async def test_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + + data = incl_vat | start | end + + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize( + ("service_data", "error", "error_message"), + [ + ({}, vol.er.Error, "required key not provided .+"), + ( + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"incl_vat": True, "start": "incorrect date"}, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"incl_vat": True, "end": "incorrect date"}, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + service_data: dict[str, str], + error: type[Exception], + error_message: str, +) -> None: + """Test the EnergyZero Service validation.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + service_data, + blocking=True, + return_response=True, + ) From dfc7ff8e646e88d8ba55b16f157f3aceba759d5b Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 21 Dec 2023 17:28:42 +0000 Subject: [PATCH 592/927] Bump pyvlx to 0.2.21 (#105800) * Bump pyvlx to 0.2.21 * Fix typing * Optimize fix --- homeassistant/components/velux/__init__.py | 4 ++-- homeassistant/components/velux/cover.py | 13 +++++++------ homeassistant/components/velux/light.py | 2 ++ homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index b43ee39ed4e..d6a5f540c06 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import OpeningDevice, PyVLX, PyVLXException +from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,7 +90,7 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node: OpeningDevice) -> None: + def __init__(self, node: Node) -> None: """Initialize the Velux device.""" self.node = node self._attr_unique_id = node.serial_number diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 48c09a2b3c2..c8fb2aafb96 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,7 +1,7 @@ """Support for Velux covers.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from pyvlx import OpeningDevice, Position from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window @@ -40,6 +40,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" _is_blind = False + node: OpeningDevice def __init__(self, node: OpeningDevice) -> None: """Initialize VeluxCover.""" @@ -86,7 +87,7 @@ class VeluxCover(VeluxEntity, CoverEntity): def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" if self._is_blind: - return 100 - self.node.orientation.position_percent + return 100 - cast(Blind, self.node).orientation.position_percent return None @property @@ -116,20 +117,20 @@ class VeluxCover(VeluxEntity, CoverEntity): async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" - await self.node.close_orientation(wait_for_completion=False) + await cast(Blind, self.node).close_orientation(wait_for_completion=False) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" - await self.node.open_orientation(wait_for_completion=False) + await cast(Blind, self.node).open_orientation(wait_for_completion=False) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" - await self.node.stop_orientation(wait_for_completion=False) + await cast(Blind, self.node).stop_orientation(wait_for_completion=False) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move cover tilt to a specific position.""" position_percent = 100 - kwargs[ATTR_TILT_POSITION] orientation = Position(position_percent=position_percent) - await self.node.set_orientation( + await cast(Blind, self.node).set_orientation( orientation=orientation, wait_for_completion=False ) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a600aceedd2..a6d63436ecf 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -35,6 +35,8 @@ class VeluxLight(VeluxEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS + node: LighteningDevice + @property def brightness(self): """Return the current brightness.""" diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 0495ff80a43..901034aa387 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.20"] + "requirements": ["pyvlx==0.2.21"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46cd06c365e..7a4cec7cef6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2287,7 +2287,7 @@ pyvesync==2.1.10 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.20 +pyvlx==0.2.21 # homeassistant.components.volumio pyvolumio==0.1.5 From f9c096687fb6c6d59d2e1e82a4acb80ba9da8960 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Thu, 21 Dec 2023 20:18:20 +0100 Subject: [PATCH 593/927] Bump Devialet to 1.4.5 (#106184) * Bump Devialet==1.4.4 * Bump Devialet to 1.4.5 --- homeassistant/components/devialet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json index e09485a8599..dd30f91c835 100644 --- a/homeassistant/components/devialet/manifest.json +++ b/homeassistant/components/devialet/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/devialet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["devialet==1.4.4"], + "requirements": ["devialet==1.4.5"], "zeroconf": ["_devialet-http._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a4cec7cef6..af0b9d63679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ demetriek==0.4.0 denonavr==0.11.4 # homeassistant.components.devialet -devialet==1.4.4 +devialet==1.4.5 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e65c3a093c7..7385ed2e953 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -561,7 +561,7 @@ demetriek==0.4.0 denonavr==0.11.4 # homeassistant.components.devialet -devialet==1.4.4 +devialet==1.4.5 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 From 54f460b7c9d25dffb7edf1fb88bac1b600b3ac0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Dec 2023 09:36:09 -1000 Subject: [PATCH 594/927] Improve performance of dhcp on high activity networks (#105884) * Improve performance of dhcp on high activity networks Most of the overhead was constructing IPAddress objects which is solved with https://github.com/bdraco/cached-ipaddress * fix test * fix: bump * bump again * lets do it on the correct branch --- homeassistant/components/dhcp/__init__.py | 7 +++-- homeassistant/components/dhcp/manifest.json | 6 ++++- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ tests/components/dhcp/test_init.py | 30 +++++++++++++++++++++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 2afe53422fb..b8a12a937e3 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -9,7 +9,6 @@ from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache -from ipaddress import ip_address as make_ip_address import logging import os import re @@ -22,6 +21,7 @@ from aiodiscover.discovery import ( IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) +from cached_ipaddress import cached_ip_addresses from scapy.config import conf from scapy.error import Scapy_Exception @@ -153,7 +153,10 @@ class WatcherBase(ABC): self, ip_address: str, hostname: str, mac_address: str ) -> None: """Process a client.""" - made_ip_address = make_ip_address(ip_address) + if (made_ip_address := cached_ip_addresses(ip_address)) is None: + # Ignore invalid addresses + _LOGGER.debug("Ignoring invalid IP Address: %s", ip_address) + return if ( made_ip_address.is_link_local diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index db6e5948196..f190f0ab10e 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,9 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.6.0"] + "requirements": [ + "scapy==2.5.0", + "aiodiscover==1.6.0", + "cached_ipaddress==0.3.0" + ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 721801c176d..3f95e36a00b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,6 +16,7 @@ bleak==0.21.1 bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.19.0 +cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index af0b9d63679..530670b62bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -608,6 +608,9 @@ btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav caldav==1.3.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7385ed2e953..665a4a5fa05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -507,6 +507,9 @@ bthome-ble==3.3.1 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav caldav==1.3.8 diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 5013568ad39..a63300b1ea2 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -828,6 +828,36 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi assert len(mock_init.mock_calls) == 0 +async def test_device_tracker_invalid_ip_address( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test an invalid ip address.""" + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + device_tracker_watcher = dhcp.DeviceTrackerWatcher( + hass, + {}, + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + hass.states.async_set( + "device_tracker.august_connect", + STATE_HOME, + { + ATTR_IP: "invalid", + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_MAC: "B8:B7:F1:6D:B5:33", + }, + ) + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + await hass.async_block_till_done() + + assert "Ignoring invalid IP Address: invalid" in caplog.text + assert len(mock_init.mock_calls) == 0 + + async def test_device_tracker_ignore_self_assigned_ips_before_start( hass: HomeAssistant, ) -> None: From 7e685f2bc7bc0de349b1300722dc8f462205bddb Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Thu, 21 Dec 2023 20:38:00 +0100 Subject: [PATCH 595/927] Swiss public transport config flow (#105648) * add config flow * unit tests * yaml config import flow * change deprecation period and simply code * keep name for legacy yaml - removing the name now would break current implementations - it will be removed together with the deprectation of yaml config flow * improve error handling, simpler unique_id, cleanup * simplify issues for yaml import flow * improve typing and clean name handling * streamline unit tests - happy path + errors - mock opendata instead of aiohttp * parametrize unit tests * improve strings * add missing aborts * update coverage ignore * remove redundant test * minor clean up of constants --- .coveragerc | 1 + CODEOWNERS | 3 +- .../swiss_public_transport/__init__.py | 63 ++++++ .../swiss_public_transport/config_flow.py | 101 +++++++++ .../swiss_public_transport/const.py | 9 + .../swiss_public_transport/manifest.json | 3 +- .../swiss_public_transport/sensor.py | 94 +++++--- .../swiss_public_transport/strings.json | 39 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + .../swiss_public_transport/__init__.py | 1 + .../swiss_public_transport/conftest.py | 15 ++ .../test_config_flow.py | 200 ++++++++++++++++++ 14 files changed, 501 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/swiss_public_transport/config_flow.py create mode 100644 homeassistant/components/swiss_public_transport/const.py create mode 100644 homeassistant/components/swiss_public_transport/strings.json create mode 100644 tests/components/swiss_public_transport/__init__.py create mode 100644 tests/components/swiss_public_transport/conftest.py create mode 100644 tests/components/swiss_public_transport/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 3d34939dbd9..adb0458d6f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1243,6 +1243,7 @@ omit = homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py + homeassistant/components/swiss_public_transport/__init__.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index b6c0e75e674..1ed96218424 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1262,7 +1262,8 @@ build.json @home-assistant/supervisor /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/swiss_hydrological_data/ @fabaff -/homeassistant/components/swiss_public_transport/ @fabaff +/homeassistant/components/swiss_public_transport/ @fabaff @miaucl +/tests/components/swiss_public_transport/ @fabaff @miaucl /homeassistant/components/switch/ @home-assistant/core /tests/components/switch/ @home-assistant/core /homeassistant/components/switch_as_x/ @home-assistant/core diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index c53cb1f6934..37f1eeb6765 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -1 +1,64 @@ """The swiss_public_transport component.""" +import logging + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) + +from homeassistant import config_entries, core +from homeassistant.const import Platform +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DESTINATION, CONF_START, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up Swiss public transport from a config entry.""" + config = entry.data + + start = config[CONF_START] + destination = config[CONF_DESTINATION] + + session = async_get_clientsession(hass) + opendata = OpendataTransport(start, destination, session) + + try: + await opendata.async_get_data() + except OpendataTransportConnectionError as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for entry '{start} {destination}'" + ) from e + except OpendataTransportError as e: + _LOGGER.error( + "Setup failed for entry '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names are valid", + start, + destination, + ) + raise ConfigEntryError( + f"Setup failed for entry '{start} {destination}' with invalid data" + ) from e + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = opendata + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py new file mode 100644 index 00000000000..534099f09e6 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for swiss_public_transport.""" +import logging +from typing import Any + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +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_DESTINATION, CONF_START, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_START): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Swiss public transport config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Async user step to set up the connection.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_START: user_input[CONF_START], + CONF_DESTINATION: user_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + user_input[CONF_START], user_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + errors["base"] = "cannot_connect" + except OpendataTransportError: + errors["base"] = "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: + """Async import step to set up the connection.""" + self._async_abort_entries_match( + { + CONF_START: import_input[CONF_START], + CONF_DESTINATION: import_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + import_input[CONF_START], import_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + return self.async_abort(reason="cannot_connect") + except OpendataTransportError: + return self.async_abort(reason="bad_config") + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Unknown error raised by python-opendata-transport for '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names and your parameters are valid", + import_input[CONF_START], + import_input[CONF_DESTINATION], + ) + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=import_input[CONF_NAME], + data=import_input, + ) diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py new file mode 100644 index 00000000000..3ce351498ee --- /dev/null +++ b/homeassistant/components/swiss_public_transport/const.py @@ -0,0 +1,9 @@ +"""Constants for the swiss_public_transport integration.""" + +DOMAIN = "swiss_public_transport" + + +CONF_DESTINATION = "to" +CONF_START = "from" + +DEFAULT_NAME = "Next Destination" diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index fd9908bffeb..c68cee2c0e1 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -1,7 +1,8 @@ { "domain": "swiss_public_transport", "name": "Swiss public transport", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@miaucl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 12007e1741c..e8c6e429d36 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -4,21 +4,27 @@ from __future__ import annotations from datetime import timedelta import logging -from opendata_transport import OpendataTransport from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol +from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN + _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=90) + ATTR_DEPARTURE_TIME1 = "next_departure" ATTR_DEPARTURE_TIME2 = "next_on_departure" ATTR_DURATION = "duration" @@ -30,14 +36,6 @@ ATTR_TRAIN_NUMBER = "train_number" ATTR_TRANSFERS = "transfers" ATTR_DELAY = "delay" -CONF_DESTINATION = "to" -CONF_START = "from" - -DEFAULT_NAME = "Next Departure" - - -SCAN_INTERVAL = timedelta(seconds=90) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DESTINATION): cv.string, @@ -47,31 +45,65 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + opendata = hass.data[DOMAIN][config_entry.entry_id] + + start = config_entry.data[CONF_START] + destination = config_entry.data[CONF_DESTINATION] + name = config_entry.title + + async_add_entities( + [SwissPublicTransportSensor(opendata, start, destination, name)], + update_before_add=True, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Swiss public transport sensor.""" - - name = config.get(CONF_NAME) - start = config.get(CONF_START) - destination = config.get(CONF_DESTINATION) - - session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session) - - try: - await opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error( - "Check at http://transport.opendata.ch/examples/stationboard.html " - "if your station names are valid" + """Set up the sensor platform.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swiss public transport", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", ) - return - - async_add_entities([SwissPublicTransportSensor(opendata, start, destination, name)]) class SwissPublicTransportSensor(SensorEntity): @@ -86,7 +118,7 @@ class SwissPublicTransportSensor(SensorEntity): self._name = name self._from = start self._to = destination - self._remaining_time = "" + self._remaining_time = None @property def name(self): @@ -129,7 +161,7 @@ class SwissPublicTransportSensor(SensorEntity): """Get the latest data from opendata.ch and update the states.""" try: - if self._remaining_time.total_seconds() < 0: + if not self._remaining_time or self._remaining_time.total_seconds() < 0: await self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json new file mode 100644 index 00000000000..097252634ea --- /dev/null +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "step": { + "user": { + "data": { + "from": "Start station", + "to": "End station" + }, + "description": "Provide start and end station for your connection\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)", + "title": "Swiss Public Transport" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The swiss public transport YAML configuration import cannot connect to server", + "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server](http://transport.opendata.ch). In case the server is down, try again later." + }, + "deprecated_yaml_import_issue_bad_config": { + "title": "The swiss public transport YAML configuration import request failed due to bad config", + "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration..\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)" + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The swiss public transport YAML configuration import failed with unknown error raised by python-opendata-transport", + "description": "Configuring swiss public transport using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 260efa41886..df69af184ac 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -475,6 +475,7 @@ FLOWS = { "sun", "sunweg", "surepetcare", + "swiss_public_transport", "switchbee", "switchbot", "switchbot_cloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 448b69e6da7..0a3229d73b2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5613,7 +5613,7 @@ "swiss_public_transport": { "name": "Swiss public transport", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "swisscom": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 665a4a5fa05..8509b76fd94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1655,6 +1655,9 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 +# homeassistant.components.swiss_public_transport +python-opendata-transport==0.3.0 + # homeassistant.components.opensky python-opensky==1.0.0 diff --git a/tests/components/swiss_public_transport/__init__.py b/tests/components/swiss_public_transport/__init__.py new file mode 100644 index 00000000000..3859a630c31 --- /dev/null +++ b/tests/components/swiss_public_transport/__init__.py @@ -0,0 +1 @@ +"""Tests for the swiss_public_transport integration.""" diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py new file mode 100644 index 00000000000..d84446db086 --- /dev/null +++ b/tests/components/swiss_public_transport/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the swiss_public_transport 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.swiss_public_transport.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py new file mode 100644 index 00000000000..55ad51c45c4 --- /dev/null +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -0,0 +1,200 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.swiss_public_transport import config_flow +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + + +async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test unknown errors.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_STEP, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +MOCK_DATA_IMPORT = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", + CONF_NAME: "test_name", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_import_cannot_connect_error( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test import flow cannot_connect error.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_IMPORT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 9ba53e03ee3a4915ce1e692bbd77bebb40c5afd8 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 21 Dec 2023 23:11:00 +0300 Subject: [PATCH 596/927] Bump httpx to 0.26.0 and httpcore to 1.0.2 (#106194) --- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- script/gen_requirements_all.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3f95e36a00b..d6d601e5ca4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ hassil==1.5.1 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20231208.2 home-assistant-intents==2023.12.05 -httpx==0.25.0 +httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 @@ -111,7 +111,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/pyproject.toml b/pyproject.toml index f92baa71288..7b9b7dd55db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.25.0", + "httpx==0.26.0", "home-assistant-bluetooth==1.11.0", "ifaddr==0.2.0", "Jinja2==3.1.2", diff --git a/requirements.txt b/requirements.txt index b9430b1bc91..378e422116b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ awesomeversion==23.11.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.25.0 +httpx==0.26.0 home-assistant-bluetooth==1.11.0 ifaddr==0.2.0 Jinja2==3.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5356ee8663b..bcd19b97e08 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -105,7 +105,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation From d144d6cd681fbfbf828074b804bfa6ad7dc57ae0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 21 Dec 2023 22:01:49 +0100 Subject: [PATCH 597/927] Update mypy to 1.8.0 (#106189) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 27e5bfaff6b..68395d9b867 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.1 coverage==7.3.3 freezegun==1.3.1 mock-open==1.4.0 -mypy==1.7.1 +mypy==1.8.0 pre-commit==3.6.0 pydantic==1.10.12 pylint==3.0.3 From 9fbc15c28b91dce3838b4676fd63c818e5d0d74b Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Thu, 21 Dec 2023 22:17:43 +0100 Subject: [PATCH 598/927] Bump python-opendata-transport to 0.4.0 (#106199) bump version to 0.4.0 --- homeassistant/components/swiss_public_transport/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index c68cee2c0e1..6f8e603bbe7 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], - "requirements": ["python-opendata-transport==0.3.0"] + "requirements": ["python-opendata-transport==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 530670b62bd..26d80967262 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2202,7 +2202,7 @@ python-mpd2==3.0.5 python-mystrom==2.2.0 # homeassistant.components.swiss_public_transport -python-opendata-transport==0.3.0 +python-opendata-transport==0.4.0 # homeassistant.components.opensky python-opensky==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8509b76fd94..fa7e272dcc5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1656,7 +1656,7 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.swiss_public_transport -python-opendata-transport==0.3.0 +python-opendata-transport==0.4.0 # homeassistant.components.opensky python-opensky==1.0.0 From c4c422de79ceb6729ef7710f26851ac407a25ed6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 21 Dec 2023 23:19:40 +0100 Subject: [PATCH 599/927] Skip logging deprecated constant if the calling integration couldn't be indentified (#106181) * Add option to log only if a integreation is detected for a deprecated constant * Require param * Add test that log entry is not created * typo --- homeassistant/helpers/deprecation.py | 19 +++++++----- tests/helpers/test_deprecation.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index fd3fb50efd4..6c78055e0b1 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -161,6 +161,7 @@ def _print_deprecation_warning( description, verb, breaks_in_ha_version, + log_when_no_integration_is_found=True, ) @@ -171,6 +172,8 @@ def _print_deprecation_warning_internal( description: str, verb: str, breaks_in_ha_version: str | None, + *, + log_when_no_integration_is_found: bool, ) -> None: logger = logging.getLogger(module_name) if breaks_in_ha_version: @@ -180,13 +183,14 @@ def _print_deprecation_warning_internal( try: integration_frame = get_integration_frame() except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s%s. Use %s instead", - obj_name, - description, - breaks_in, - replacement, - ) + if log_when_no_integration_is_found: + logger.warning( + "%s is a deprecated %s%s. Use %s instead", + obj_name, + description, + breaks_in, + replacement, + ) else: if integration_frame.custom_integration: hass: HomeAssistant | None = None @@ -280,6 +284,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A "constant", "used", breaks_in_ha_version, + log_when_no_integration_is_found=False, ) return value diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index cb90d8e2bed..ef3be2d2ef8 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -17,6 +17,7 @@ from homeassistant.helpers.deprecation import ( dir_with_deprecated_constants, get_deprecated, ) +from homeassistant.helpers.frame import MissingIntegrationFrame from tests.common import MockModule, mock_integration @@ -324,6 +325,51 @@ def test_check_if_deprecated_constant( ) in caplog.record_tuples +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name"), + [ + "homeassistant.components.hue.light", # builtin integration + "config.custom_components.hue.light", # custom component integration + ], +) +def test_check_if_deprecated_constant_integration_not_found( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum, + extra_msg: str, + module_name: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + + with patch( + "homeassistant.helpers.frame.extract_stack", side_effect=MissingIntegrationFrame + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == deprecated_constant.value + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT is a deprecated constant{extra_msg}", + ) not in caplog.record_tuples + + def test_test_check_if_deprecated_constant_invalid( caplog: pytest.LogCaptureFixture, ) -> None: From 69f8514556a723b32218858c3369539fbeb97cd3 Mon Sep 17 00:00:00 2001 From: Diogo Alves Date: Thu, 21 Dec 2023 22:28:19 +0000 Subject: [PATCH 600/927] Fix lacrosse view sensor units (#106203) Fixed Unit values from sensor.py There was a mistake on the units from the sensor that where messing up the device readings on rain, windchill and feels like sensors --- homeassistant/components/lacrosse_view/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index e347a1409f6..16da95ed598 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -97,7 +97,7 @@ SENSOR_DESCRIPTIONS = { key="Rain", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, - native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), "WindHeading": LaCrosseSensorEntityDescription( @@ -130,7 +130,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -138,7 +138,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), } From e75357980a3d124ced4b9bf86bd6367b28e84b77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Dec 2023 13:03:42 -1000 Subject: [PATCH 601/927] Bump aiohttp-zlib-ng to 0.1.2 (#106193) fixes #105254 --- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index c68ecd79d5f..87b6d5c3902 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.1" + "aiohttp-zlib-ng==0.1.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d6d601e5ca4..40ead87a5bc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.2 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 7b9b7dd55db..1569e0a7cc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.1", + "aiohttp-zlib-ng==0.1.2", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 378e422116b..b14ae118d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.2 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 26d80967262..b02adaf1aee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.0 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7e272dcc5..00fc7922b5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.0 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.2 # homeassistant.components.emulated_hue # homeassistant.components.http From 88ea5f7a5408ab80dadf6e1eead3275d0bf5b4f0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Dec 2023 00:38:49 +0100 Subject: [PATCH 602/927] Use call_soon_threadsafe in face processing of image_processing (#106168) --- homeassistant/components/image_processing/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 916812e41c9..0852fa85e1e 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -24,7 +24,6 @@ from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -233,9 +232,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): def process_faces(self, faces: list[FaceInformation], total: int) -> None: """Send event with detected faces and store data.""" - run_callback_threadsafe( - self.hass.loop, self.async_process_faces, faces, total - ).result() + self.hass.loop.call_soon_threadsafe(self.async_process_faces, faces, total) @callback def async_process_faces(self, faces: list[FaceInformation], total: int) -> None: From 8b08b5e8d23056856bbb4e7d7b1fd0d62ec69f80 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Dec 2023 00:42:16 +0100 Subject: [PATCH 603/927] Deprecate legacy_templates (#105556) * Deprecate legacy_templates * Improve wording * Implement suggestion * simplify * Add deleting of the repair issues back * Update homeassistant/components/homeassistant/strings.json Co-authored-by: Martin Hjelmare * Test issue removal too --------- Co-authored-by: Martin Hjelmare --- .../components/homeassistant/strings.json | 8 +++++ homeassistant/config.py | 36 +++++++++++++++++++ tests/test_config.py | 24 +++++++++++++ 3 files changed, 68 insertions(+) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 6981bdfe685..862ac12cefb 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,6 +12,14 @@ "title": "The configured currency is no longer in use", "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." }, + "legacy_templates_false": { + "title": "`legacy_templates` config key is being removed", + "description": "Nothing will change with your templates.\n\nRemove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "legacy_templates_true": { + "title": "The support for legacy templates is being removed", + "description": "Please do the following steps:\n- Adopt your configuration to support template rendering to native python types.\n- Remove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file.\n- Restart Home Assistant to fix this issue." + }, "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." diff --git a/homeassistant/config.py b/homeassistant/config.py index d5b6864c937..949774d3361 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -272,6 +272,41 @@ def _raise_issue_if_no_country(hass: HomeAssistant, country: str | None) -> None ) +def _raise_issue_if_legacy_templates( + hass: HomeAssistant, legacy_templates: bool | None +) -> None: + # legacy_templates can have the following values: + # - None: Using default value (False) -> Delete repair issues + # - True: Create repair to adopt templates to new syntax + # - False: Create repair to tell user to remove config key + if legacy_templates: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_true", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_true", + ) + return + + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_true") + + if legacy_templates is False: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_false", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_false", + ) + else: + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_false") + + def _validate_currency(data: Any) -> Any: try: return cv.currency(data) @@ -840,6 +875,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if key in config: setattr(hac, attr, config[key]) + _raise_issue_if_legacy_templates(hass, config.get(CONF_LEGACY_TEMPLATES)) _raise_issue_if_historic_currency(hass, hass.config.currency) _raise_issue_if_no_country(hass, hass.config.country) diff --git a/tests/test_config.py b/tests/test_config.py index 8ec509cd895..0f6b36d90b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1980,6 +1980,30 @@ async def test_core_config_schema_no_country(hass: HomeAssistant) -> None: assert issue +@pytest.mark.parametrize( + ("config", "expected_issue"), + [ + ({}, None), + ({"legacy_templates": True}, "legacy_templates_true"), + ({"legacy_templates": False}, "legacy_templates_false"), + ], +) +async def test_core_config_schema_legacy_template( + hass: HomeAssistant, config: dict[str, Any], expected_issue: str | None +) -> None: + """Test legacy_template core config schema.""" + await config_util.async_process_ha_core_config(hass, config) + + issue_registry = ir.async_get(hass) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + issue = issue_registry.async_get_issue("homeassistant", issue_id) + assert issue if issue_id == expected_issue else not issue + + await config_util.async_process_ha_core_config(hass, {}) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + assert not issue_registry.async_get_issue("homeassistant", issue_id) + + async def test_core_store_no_country( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From e86ac568e17f02cd82f2b1564dae255415e057ed Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 10:28:58 +1000 Subject: [PATCH 604/927] Add device tracker to Tessie (#105428) Co-authored-by: J. Nick Koston --- homeassistant/components/tessie/__init__.py | 1 + .../components/tessie/device_tracker.py | 86 +++++++++++++++++++ homeassistant/components/tessie/strings.json | 15 ++++ .../components/tessie/test_device_tracker.py | 36 ++++++++ 4 files changed, 138 insertions(+) create mode 100644 homeassistant/components/tessie/device_tracker.py create mode 100644 tests/components/tessie/test_device_tracker.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 63562faeb60..b360541ef1e 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -17,6 +17,7 @@ from .coordinator import TessieDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py new file mode 100644 index 00000000000..330623e55b4 --- /dev/null +++ b/homeassistant/components/tessie/device_tracker.py @@ -0,0 +1,86 @@ +"""Device Tracker platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie device tracker platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + klass(coordinator) + for klass in ( + TessieDeviceTrackerLocationEntity, + TessieDeviceTrackerRouteEntity, + ) + for coordinator in coordinators + ) + + +class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): + """Base class for Tessie Tracker Entities.""" + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, self.key) + + @property + def source_type(self) -> SourceType | str: + """Return the source type of the device tracker.""" + return SourceType.GPS + + +class TessieDeviceTrackerLocationEntity(TessieDeviceTrackerEntity): + """Vehicle Location Device Tracker Class.""" + + _attr_name = None + key = "location" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_latitude") + + @property + def extra_state_attributes(self) -> dict[str, StateType] | None: + """Return device state attributes.""" + return { + "heading": self.get("drive_state_heading"), + "speed": self.get("drive_state_speed"), + } + + +class TessieDeviceTrackerRouteEntity(TessieDeviceTrackerEntity): + """Vehicle Navigation Device Tracker Class.""" + + key = "route" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_active_route_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_active_route_latitude") diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index f1279ab0daf..a583a6d66eb 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -22,6 +22,21 @@ } }, "entity": { + "device_tracker": { + "location": { + "state_attributes": { + "heading": { + "name": "Heading" + }, + "speed": { + "name": "Speed" + } + } + }, + "route": { + "name": "Route" + } + }, "climate": { "primary": { "name": "[%key:component::climate::title%]", diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py new file mode 100644 index 00000000000..8b42051a10b --- /dev/null +++ b/tests/components/tessie/test_device_tracker.py @@ -0,0 +1,36 @@ +"""Test the Tessie device tracker platform.""" + + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant + +from .common import TEST_STATE_OF_ALL_VEHICLES, setup_platform + +STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] + + +async def test_device_tracker(hass: HomeAssistant) -> None: + """Tests that the device trackers are correct.""" + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 2 + + entity_id = "device_tracker.test" + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_LATITUDE) == STATES["drive_state"]["latitude"] + assert state.attributes.get(ATTR_LONGITUDE) == STATES["drive_state"]["longitude"] + + entity_id = "device_tracker.test_route" + state = hass.states.get(entity_id) + assert ( + state.attributes.get(ATTR_LATITUDE) + == STATES["drive_state"]["active_route_latitude"] + ) + assert ( + state.attributes.get(ATTR_LONGITUDE) + == STATES["drive_state"]["active_route_longitude"] + ) From 8918a9c2c4cddd956fec534449fbf5cc26e19890 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 12:40:55 +1000 Subject: [PATCH 605/927] Add button platform to Tessie (#106210) --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/button.py | 84 ++++++++++++++++++++ homeassistant/components/tessie/strings.json | 8 ++ tests/components/tessie/common.py | 17 +++- tests/components/tessie/test_button.py | 24 ++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tessie/button.py create mode 100644 tests/components/tessie/test_button.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index b360541ef1e..608be7692dc 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -16,6 +16,7 @@ from .coordinator import TessieDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.DEVICE_TRACKER, Platform.SELECT, diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py new file mode 100644 index 00000000000..fb4449f5898 --- /dev/null +++ b/homeassistant/components/tessie/button.py @@ -0,0 +1,84 @@ +"""Button platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import ( + boombox, + enable_keyless_driving, + flash_lights, + honk, + open_close_rear_trunk, + open_front_trunk, + trigger_homelink, + wake, +) + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieButtonEntityDescription(ButtonEntityDescription): + """Describes a Tessie Button entity.""" + + func: Callable + + +DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( + TessieButtonEntityDescription(key="wake", func=wake, icon="mdi:sleep-off"), + TessieButtonEntityDescription( + key="flash_lights", func=flash_lights, icon="mdi:flashlight" + ), + TessieButtonEntityDescription(key="honk", func=honk, icon="mdi:bullhorn"), + TessieButtonEntityDescription( + key="trigger_homelink", func=trigger_homelink, icon="mdi:garage" + ), + TessieButtonEntityDescription( + key="enable_keyless_driving", func=enable_keyless_driving, icon="mdi:car-key" + ), + TessieButtonEntityDescription(key="boombox", func=boombox, icon="mdi:volume-high"), + TessieButtonEntityDescription(key="frunk", func=open_front_trunk, icon="mdi:car"), + TessieButtonEntityDescription( + key="trunk", func=open_close_rear_trunk, icon="mdi:car-back" + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Button platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieButtonEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + ) + + +class TessieButtonEntity(TessieEntity, ButtonEntity): + """Base class for Tessie Buttons.""" + + entity_description: TessieButtonEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieButtonEntityDescription, + ) -> None: + """Initialize the Button.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + async def async_press(self) -> None: + """Press the button.""" + await self.run(self.entity_description.func) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index a583a6d66eb..a5de4e758e2 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -241,6 +241,14 @@ "name": "Tire pressure warning rear right" } }, + "button": { + "wake": { "name": "Wake" }, + "flash_lights": { "name": "Flash lights" }, + "honk": { "name": "Honk horn" }, + "trigger_homelink": { "name": "Homelink" }, + "enable_keyless_driving": { "name": "Keyless driving" }, + "boombox": { "name": "Play fart" } + }, "switch": { "charge_state_charge_enable_request": { "name": "Charge" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 30b6feca4d7..12b001a83e6 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -1,7 +1,8 @@ """Tessie common helpers for tests.""" +from contextlib import contextmanager from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo @@ -9,6 +10,7 @@ from aiohttp.client import RequestInfo from homeassistant.components.tessie.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from tests.common import MockConfigEntry, load_json_object_fixture @@ -54,3 +56,16 @@ async def setup_platform(hass: HomeAssistant, side_effect=None): await hass.async_block_till_done() return mock_entry + + +@contextmanager +def patch_description( + key: str, attr: str, descriptions: tuple[EntityDescription] +) -> AsyncMock: + """Patch a description.""" + to_patch = next(filter(lambda x: x.key == key, descriptions)) + original = to_patch.func + mock = AsyncMock() + object.__setattr__(to_patch, attr, mock) + yield mock + object.__setattr__(to_patch, attr, original) diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py new file mode 100644 index 00000000000..cd98377cb0c --- /dev/null +++ b/tests/components/tessie/test_button.py @@ -0,0 +1,24 @@ +"""Test the Tessie button platform.""" + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.tessie.button import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import patch_description, setup_platform + + +async def test_buttons(hass: HomeAssistant) -> None: + """Tests that the buttons are correct.""" + + await setup_platform(hass) + + # Test wake button + with patch_description("wake", "func", DESCRIPTIONS) as mock_wake: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_wake"]}, + blocking=True, + ) + mock_wake.assert_called_once() From 7ef20c4431b6a3f1e262272d639940ddd7c92456 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Fri, 22 Dec 2023 07:37:57 +0100 Subject: [PATCH 606/927] Fix Netatmo light switching states by assuming state until next update (#106162) --- homeassistant/components/netatmo/light.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index e3bd8952b55..b796372fc20 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -186,11 +186,6 @@ class NetatmoLight(NetatmoBase, LightEntity): ] ) - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._dimmer.on is True - async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" if ATTR_BRIGHTNESS in kwargs: @@ -211,6 +206,8 @@ class NetatmoLight(NetatmoBase, LightEntity): @callback def async_update_callback(self) -> None: """Update the entity's state.""" + self._attr_is_on = self._dimmer.on is True + if self._dimmer.brightness is not None: # Netatmo uses a range of [0, 100] to control brightness self._attr_brightness = round((self._dimmer.brightness / 100) * 255) From 72da0a0e1dce21f357454b2ff791eb6461678847 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Fri, 22 Dec 2023 01:04:16 -0800 Subject: [PATCH 607/927] Bump pylitejet to v0.6.2 (#106222) Bump pylitejet to 0.6.2 --- homeassistant/components/litejet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 8525bb9ff17..65dde31436d 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.6.0"] + "requirements": ["pylitejet==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b02adaf1aee..4ba4b8ab03e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1880,7 +1880,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.6.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00fc7922b5c..9623f502472 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1424,7 +1424,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.6.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 From abc57ea706232a168da86e2dafbc7003d58c22da Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 19:11:18 +1000 Subject: [PATCH 608/927] Quality improvements for Tessie (#106218) Quality improvements --- homeassistant/components/tessie/switch.py | 2 +- homeassistant/components/tessie/update.py | 1 - tests/components/tessie/test_binary_sensors.py | 2 +- tests/components/tessie/test_button.py | 2 +- tests/components/tessie/test_config_flow.py | 2 +- tests/components/tessie/test_coordinator.py | 2 +- tests/components/tessie/test_device_tracker.py | 2 +- tests/components/tessie/test_init.py | 6 +++--- tests/components/tessie/test_select.py | 2 +- tests/components/tessie/test_sensor.py | 2 +- tests/components/tessie/test_switch.py | 2 +- tests/components/tessie/test_update.py | 2 +- 12 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 216f48da348..179beafe290 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -38,7 +38,6 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable - device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( @@ -94,6 +93,7 @@ async def async_setup_entry( class TessieSwitchEntity(TessieEntity, SwitchEntity): """Base class for Tessie Switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TessieSwitchEntityDescription def __init__( diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 88899b61320..4a3c06df6e2 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -42,7 +42,6 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): @property def latest_version(self) -> str | None: """Return the latest version.""" - # Dont show an update when its not in a state that can be actioned if self.get("vehicle_state_software_update_status") in ( TessieUpdateStatus.AVAILABLE, TessieUpdateStatus.SCHEDULED, diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py index 594b270ec7a..7f1eb1805a2 100644 --- a/tests/components/tessie/test_binary_sensors.py +++ b/tests/components/tessie/test_binary_sensors.py @@ -10,7 +10,7 @@ OFFON = [STATE_OFF, STATE_ON] async def test_binary_sensors(hass: HomeAssistant) -> None: - """Tests that the sensors are correct.""" + """Tests that the binary sensor entities are correct.""" assert len(hass.states.async_all("binary_sensor")) == 0 diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index cd98377cb0c..6b20dd858a7 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -9,7 +9,7 @@ from .common import patch_description, setup_platform async def test_buttons(hass: HomeAssistant) -> None: - """Tests that the buttons are correct.""" + """Tests that the button entities are correct.""" await setup_platform(hass) diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index 182468e200c..7bc3efa24fc 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -58,7 +58,7 @@ async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None async def test_form_errors( hass: HomeAssistant, side_effect, error, mock_get_state_of_all_vehicles ) -> None: - """Test invalid auth is handled.""" + """Test errors are handled.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 6fc263e6908..311222466fd 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -21,7 +21,7 @@ WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: - """Tests that the coordinator handles online vehciles.""" + """Tests that the coordinator handles online vehicles.""" mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE await setup_platform(hass) diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index 8b42051a10b..1ea4ee839be 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -11,7 +11,7 @@ STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] async def test_device_tracker(hass: HomeAssistant) -> None: - """Tests that the device trackers are correct.""" + """Tests that the device tracker entities are correct.""" assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 8c12979b9d5..68d6fcf7777 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -17,21 +17,21 @@ async def test_load_unload(hass: HomeAssistant) -> None: async def test_auth_failure(hass: HomeAssistant) -> None: - """Test init with an authentication failure.""" + """Test init with an authentication error.""" entry = await setup_platform(hass, side_effect=ERROR_AUTH) assert entry.state is ConfigEntryState.SETUP_ERROR async def test_unknown_failure(hass: HomeAssistant) -> None: - """Test init with an authentication failure.""" + """Test init with an client response error.""" entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) assert entry.state is ConfigEntryState.SETUP_ERROR async def test_connection_failure(hass: HomeAssistant) -> None: - """Test init with a network connection failure.""" + """Test init with a network connection error.""" entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index 705e66d3dbb..09afa9306a7 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -16,7 +16,7 @@ from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform async def test_select(hass: HomeAssistant) -> None: - """Tests that the select entity is correct.""" + """Tests that the select entities are correct.""" assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index b9371032d0e..0c719f66136 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -7,7 +7,7 @@ from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform async def test_sensors(hass: HomeAssistant) -> None: - """Tests that the sensors are correct.""" + """Tests that the sensor entities are correct.""" assert len(hass.states.async_all("sensor")) == 0 diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 7ecd51bbd54..e19a7aed49e 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -14,7 +14,7 @@ from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform async def test_switches(hass: HomeAssistant) -> None: - """Tests that the switches are correct.""" + """Tests that the switche entities are correct.""" assert len(hass.states.async_all("switch")) == 0 diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index b683f80116d..1ade06d3fa7 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -6,7 +6,7 @@ from .common import setup_platform async def test_updates(hass: HomeAssistant) -> None: - """Tests that the updates are correct.""" + """Tests that update entity is correct.""" assert len(hass.states.async_all("update")) == 0 From 1170e72913466c5c3173bea9fb79d34e4d0ecd46 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 19:11:48 +1000 Subject: [PATCH 609/927] Add lock platform to Tessie (#106216) * Add lock platform * Update tests * fix test docstring --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/lock.py | 52 +++++++++++++++++++++ tests/components/tessie/test_lock.py | 50 ++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 homeassistant/components/tessie/lock.py create mode 100644 tests/components/tessie/test_lock.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 608be7692dc..ece8a3b7f4a 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.LOCK, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py new file mode 100644 index 00000000000..3342747a2f9 --- /dev/null +++ b/homeassistant/components/tessie/lock.py @@ -0,0 +1,52 @@ +"""Lock platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import lock, unlock + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieLockEntity(coordinator) for coordinator in coordinators) + + +class TessieLockEntity(TessieEntity, LockEntity): + """Lock entity for current charge.""" + + _attr_name = None + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_locked") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(lock) + self.set((self.key, True)) + + async def async_unlock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(unlock) + self.set((self.key, False)) diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py new file mode 100644 index 00000000000..38601de52c9 --- /dev/null +++ b/tests/components/tessie/test_lock.py @@ -0,0 +1,50 @@ +"""Test the Tessie lock platform.""" + +from unittest.mock import patch + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_locks(hass: HomeAssistant) -> None: + """Tests that the lock entity is correct.""" + + assert len(hass.states.async_all("lock")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("lock")) == 1 + + entity_id = "lock.test" + + assert ( + hass.states.get(entity_id).state == STATE_LOCKED + ) == TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["locked"] + + # Test lock set value functions + with patch("homeassistant.components.tessie.lock.lock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_LOCKED + mock_run.assert_called_once() + + with patch("homeassistant.components.tessie.lock.unlock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_UNLOCKED + mock_run.assert_called_once() From 23fa86cc23f93a33985484e1a44419fa142df828 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 19:17:23 +1000 Subject: [PATCH 610/927] Add cover platform to Tessie (#105422) * Add cover platform * fix case * Remove virtual key issue * Remove redundant logic * Fix logic that I missed * Add missing types * Add missing type * Update entity * Make window name better * Fix test * Update docstrings and comments --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/cover.py | 107 ++++++++++++++++++ homeassistant/components/tessie/strings.json | 8 ++ tests/components/tessie/common.py | 5 + tests/components/tessie/test_cover.py | 112 +++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 homeassistant/components/tessie/cover.py create mode 100644 tests/components/tessie/test_cover.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index ece8a3b7f4a..f3db59a65ae 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.COVER, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SELECT, diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py new file mode 100644 index 00000000000..b7834a74766 --- /dev/null +++ b/homeassistant/components/tessie/cover.py @@ -0,0 +1,107 @@ +"""Cover platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + close_charge_port, + close_windows, + open_unlock_charge_port, + vent_windows, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + Entity(coordinator) + for Entity in ( + TessieWindowEntity, + TessieChargePortEntity, + ) + for coordinator in coordinators + ) + + +class TessieWindowEntity(TessieEntity, CoverEntity): + """Cover entity for current charge.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieDataUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "windows") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return ( + self.get("vehicle_state_fd_window") == 0 + and self.get("vehicle_state_fp_window") == 0 + and self.get("vehicle_state_rd_window") == 0 + and self.get("vehicle_state_rp_window") == 0 + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(vent_windows) + self.set( + ("vehicle_state_fd_window", 1), + ("vehicle_state_fp_window", 1), + ("vehicle_state_rd_window", 1), + ("vehicle_state_rp_window", 1), + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_windows) + self.set( + ("vehicle_state_fd_window", 0), + ("vehicle_state_fp_window", 0), + ("vehicle_state_rd_window", 0), + ("vehicle_state_rp_window", 0), + ) + + +class TessieChargePortEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieDataUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "charge_state_charge_port_door_open") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return not self._value + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(open_unlock_charge_port) + self.set((self.key, True)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_charge_port) + self.set((self.key, False)) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index a5de4e758e2..9bc6dfbd9bd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -117,6 +117,14 @@ "name": "Passenger temperature setting" } }, + "cover": { + "windows": { + "name": "Vent windows" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + } + }, "select": { "climate_state_seat_heater_left": { "name": "Seat heater left", diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 12b001a83e6..c0f79d26a37 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -35,6 +35,11 @@ ERROR_TIMEOUT = ClientResponseError( ERROR_UNKNOWN = ClientResponseError( request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST ) +ERROR_VIRTUAL_KEY = ClientResponseError( + request_info=TEST_REQUEST_INFO, + history=None, + status=HTTPStatus.INTERNAL_SERVER_ERROR, +) ERROR_CONNECTION = ClientConnectionError() diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py new file mode 100644 index 00000000000..be75b6df60a --- /dev/null +++ b/tests/components/tessie/test_cover.py @@ -0,0 +1,112 @@ +"""Test the Tessie cover platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_OPEN, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform + + +async def test_window(hass: HomeAssistant) -> None: + """Tests that the window cover entity is correct.""" + + await setup_platform(hass) + + entity_id = "cover.test_vent_windows" + assert hass.states.get(entity_id).state == STATE_CLOSED + + # Test open windows + with patch( + "homeassistant.components.tessie.cover.vent_windows", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN + + # Test close windows + with patch( + "homeassistant.components.tessie.cover.close_windows", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_charge_port(hass: HomeAssistant) -> None: + """Tests that the charge port cover entity is correct.""" + + await setup_platform(hass) + + entity_id = "cover.test_charge_port_door" + assert hass.states.get(entity_id).state == STATE_OPEN + + # Test close charge port + with patch( + "homeassistant.components.tessie.cover.close_charge_port", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED + + # Test open charge port + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN From e18d2b887337696cf6f067eba0091f5e6ecbd4d3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Dec 2023 11:21:45 +0100 Subject: [PATCH 611/927] Deprecate deprecated device_registry helper constants (#106227) --- homeassistant/helpers/device_registry.py | 20 +++++++++++++++++--- tests/helpers/test_device_registry.py | 15 ++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9a26821faaf..bd509cb47ec 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Coroutine, ValuesView from enum import StrEnum +from functools import partial import logging import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast @@ -21,6 +22,11 @@ import homeassistant.util.uuid as uuid_util from . import storage from .debounce import Debouncer +from .deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data from .typing import UNDEFINED, UndefinedType @@ -61,9 +67,17 @@ class DeviceEntryDisabler(StrEnum): # DISABLED_* are deprecated, to be removed in 2022.3 -DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value -DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value -DISABLED_USER = DeviceEntryDisabler.USER.value +_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum( + DeviceEntryDisabler.CONFIG_ENTRY, "2025.1" +) +_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum( + DeviceEntryDisabler.INTEGRATION, "2025.1" +) +_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class DeviceInfo(TypedDict, total=False): diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 657d8871e66..43540a52f7d 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -17,7 +17,11 @@ from homeassistant.helpers import ( entity_registry as er, ) -from tests.common import MockConfigEntry, flush_store +from tests.common import ( + MockConfigEntry, + flush_store, + import_and_test_deprecated_constant_enum, +) @pytest.fixture @@ -2012,3 +2016,12 @@ async def test_loading_invalid_configuration_url_from_storage( identifiers={("serial", "123456ABCDEF")}, ) assert entry.configuration_url == "invalid" + + +@pytest.mark.parametrize(("enum"), list(dr.DeviceEntryDisabler)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: dr.DeviceEntryDisabler, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, dr, enum, "DISABLED_", "2025.1") From 06220849fcf1b59adf9be9ed4b7b52a0280afa43 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Dec 2023 11:23:03 +0100 Subject: [PATCH 612/927] Deprecate deprecated water_heater constants (#106226) --- .../components/water_heater/__init__.py | 21 ++++++++++++++++--- tests/components/water_heater/test_init.py | 21 ++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 6506be10065..c780407af7c 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -28,6 +28,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -65,9 +70,19 @@ class WaterHeaterEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the WaterHeaterEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_OPERATION_MODE = 2 -SUPPORT_AWAY_MODE = 4 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.OPERATION_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.AWAY_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index bc996ab6fa4..8a7d76bd891 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, @@ -13,7 +14,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum async def test_set_temp_schema_no_req( @@ -96,3 +97,21 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await water_heater.async_turn_off() assert water_heater.async_turn_off.call_count == 1 + + +@pytest.mark.parametrize( + ("enum"), + [ + WaterHeaterEntityFeature.TARGET_TEMPERATURE, + WaterHeaterEntityFeature.OPERATION_MODE, + WaterHeaterEntityFeature.AWAY_MODE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: WaterHeaterEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, water_heater, enum, "SUPPORT_", "2025.1" + ) From 4d5bea7bcc9282b58fb5d1892a6380ca3bf31a32 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Dec 2023 11:23:21 +0100 Subject: [PATCH 613/927] Deprecate deprecated switch constants (#106225) --- homeassistant/components/switch/__init__.py | 18 ++++++++++++++++-- tests/components/switch/test_init.py | 13 ++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bdbb2b7701b..1d0654cd815 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging import voluptuous as vol @@ -19,6 +20,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -47,8 +53,16 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the SwitchDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] -DEVICE_CLASS_OUTLET = SwitchDeviceClass.OUTLET.value -DEVICE_CLASS_SWITCH = SwitchDeviceClass.SWITCH.value +_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum( + SwitchDeviceClass.OUTLET, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum( + SwitchDeviceClass.SWITCH, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index cbc91d24e41..7a43e0bf50e 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -9,7 +9,7 @@ from homeassistant.setup import async_setup_component from . import common -from tests.common import MockUser +from tests.common import MockUser, import_and_test_deprecated_constant_enum @pytest.fixture(autouse=True) @@ -80,3 +80,14 @@ async def test_switch_context( assert state2 is not None assert state.state != state2.state assert state2.context.user_id == hass_admin_user.id + + +@pytest.mark.parametrize(("enum"), list(switch.SwitchDeviceClass)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: switch.SwitchDeviceClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, switch, enum, "DEVICE_CLASS_", "2025.1" + ) From 9237740103a2e38f749fc78c7175faaadb1ab10b Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Fri, 22 Dec 2023 11:37:03 +0100 Subject: [PATCH 614/927] Increase version default timeout from 10 to 30 seconds (#106158) * Update __init__.py * Update __init__.py * Revert timeout changes * Revert commit Revert timeout changes --- homeassistant/components/version/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index 878ed3d0138..f05c2147449 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -44,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: image=entry.data[CONF_IMAGE], board=BOARD_MAP[board], channel=entry.data[CONF_CHANNEL].lower(), + timeout=30, ), ) await coordinator.async_config_entry_first_refresh() From c824d06a8c5e540d8913edc3768821d3bce45269 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 20:57:21 +1000 Subject: [PATCH 615/927] Add number platform to Tessie (#106205) * Add number platform * Make self.set consistent * Fix test docstring * Use entity descriptions * Add patch_description to tests * Rename argument to arg * Make max key mandatory * Set SOC min to normal value --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/number.py | 137 +++++++++++++++++++ homeassistant/components/tessie/strings.json | 11 ++ tests/components/tessie/test_number.py | 70 ++++++++++ 4 files changed, 219 insertions(+) create mode 100644 homeassistant/components/tessie/number.py create mode 100644 tests/components/tessie/test_number.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index f3db59a65ae..e7fb41b0788 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.COVER, Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py new file mode 100644 index 00000000000..204260a7ab6 --- /dev/null +++ b/homeassistant/components/tessie/number.py @@ -0,0 +1,137 @@ +"""Number platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + UnitOfElectricCurrent, + UnitOfSpeed, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieNumberEntityDescription(NumberEntityDescription): + """Describes Tessie Number entity.""" + + func: Callable + arg: str + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + + +DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( + TessieNumberEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + max_key="charge_state_charge_current_request_max", + func=set_charging_amps, + arg="amps", + ), + TessieNumberEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=set_charge_limit, + arg="percent", + ), + TessieNumberEntityDescription( + key="vehicle_state_speed_limit_mode_current_limit_mph", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=120, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=NumberDeviceClass.SPEED, + mode=NumberMode.BOX, + min_key="vehicle_state_speed_limit_mode_min_limit_mph", + max_key="vehicle_state_speed_limit_mode_max_limit_mph", + func=set_speed_limit, + arg="mph", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieNumberEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TessieNumberEntity(TessieEntity, NumberEntity): + """Number entity for current charge.""" + + entity_description: TessieNumberEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieNumberEntityDescription, + ) -> None: + """Initialize the Number entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self._value + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if self.entity_description.min_key: + return self.get( + self.entity_description.min_key, + self.entity_description.native_min_value, + ) + return self.entity_description.native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.get( + self.entity_description.max_key, self.entity_description.native_max_value + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.run( + self.entity_description.func, **{self.entity_description.arg: value} + ) + self.set((self.key, value)) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 9bc6dfbd9bd..cb4b09ad3a4 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -273,6 +273,17 @@ "climate_state_steering_wheel_heater": { "name": "Steering wheel heater" } + }, + "number": { + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "vehicle_state_speed_limit_mode_current_limit_mph": { + "name": "Speed limit" + } } } } diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py new file mode 100644 index 00000000000..f4a407f80c4 --- /dev/null +++ b/tests/components/tessie/test_number.py @@ -0,0 +1,70 @@ +"""Test the Tessie number platform.""" + + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.tessie.number import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, patch_description, setup_platform + + +async def test_numbers(hass: HomeAssistant) -> None: + """Tests that the number entities are correct.""" + + assert len(hass.states.async_all("number")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("number")) == len(DESCRIPTIONS) + + assert hass.states.get("number.test_charge_current").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_current_request"] + ) + + assert hass.states.get("number.test_charge_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_limit_soc"] + ) + + assert hass.states.get("number.test_speed_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["speed_limit_mode"][ + "current_limit_mph" + ] + ) + + # Test number set value functions + with patch_description( + "charge_state_charge_current_request", "func", DESCRIPTIONS + ) as mock_set_charging_amps: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_current"], "value": 16}, + blocking=True, + ) + assert hass.states.get("number.test_charge_current").state == "16.0" + mock_set_charging_amps.assert_called_once() + + with patch_description( + "charge_state_charge_limit_soc", "func", DESCRIPTIONS + ) as mock_set_charge_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_limit"], "value": 80}, + blocking=True, + ) + assert hass.states.get("number.test_charge_limit").state == "80.0" + mock_set_charge_limit.assert_called_once() + + with patch_description( + "vehicle_state_speed_limit_mode_current_limit_mph", "func", DESCRIPTIONS + ) as mock_set_speed_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_speed_limit"], "value": 60}, + blocking=True, + ) + assert hass.states.get("number.test_speed_limit").state == "60.0" + mock_set_speed_limit.assert_called_once() From 2c2e6171e2e7e8da45781a9c37af355dbbcfbb24 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 22 Dec 2023 12:04:58 +0100 Subject: [PATCH 616/927] Add integration for Vogel's MotionMount (#103498) * Skeleton for Vogel's MotionMount support. * Generated updates. * Add validation of the discovered information. * Add manual configuration * Use a mac address as a unique id * Add tests for config_flow * Add a 'turn' sensor entity. * Add all needed sensors. * Add number and select entity for control of MotionMount * Update based on development checklist * Preset selector now updates when a preset is chosen * Fix adding presets selector to device * Remove irrelevant TODO * Bump python-MotionMount requirement * Invert direction of turn slider * Prepare for PR * Make sure entities have correct values when created * Use device's mac address as unique id for entities. * Fix missing files in .coveragerc * Remove typing ignore from device library. Improved typing also gave rise to the need to improve the callback mechanism * Improve typing * Convert property to shorthand form * Remove unneeded CONF_NAME in ConfigEntry * Add small comment * Refresh coordinator on notification from MotionMount * Use translation for entity * Bump python-MotionMount * Raise `ConfigEntryNotReady` when connect fails * Use local variable * Improve exception handling * Reduce duplicate code * Make better use of constants * Remove unneeded callback * Remove other occurrence of unneeded callback * Improve removal of suffix * Catch 'getaddrinfo' exception * Add config flow tests for invalid hostname * Abort if device with same hostname is already configured * Make sure we connect to a device with the same unique id as configured * Convert function names to snake_case * Remove unneeded commented-out code * Use tuple * Make us of config_entry id when mac is missing * Prevent update of entities when nothing changed * Don't store data in `hass.data` until we know we will proceed * Remove coordinator * Handle situation where mac is EMPTY_MAC * Disable polling * Fix failing hassfest * Avoid calling unique-id-less discovery handler for situations where we've an unique id --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/motionmount/__init__.py | 61 +++ .../components/motionmount/config_flow.py | 176 +++++++ homeassistant/components/motionmount/const.py | 5 + .../components/motionmount/entity.py | 53 ++ .../components/motionmount/manifest.json | 11 + .../components/motionmount/number.py | 71 +++ .../components/motionmount/strings.json | 37 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/motionmount/__init__.py | 42 ++ tests/components/motionmount/conftest.py | 44 ++ .../motionmount/test_config_flow.py | 488 ++++++++++++++++++ 19 files changed, 1022 insertions(+) create mode 100644 homeassistant/components/motionmount/__init__.py create mode 100644 homeassistant/components/motionmount/config_flow.py create mode 100644 homeassistant/components/motionmount/const.py create mode 100644 homeassistant/components/motionmount/entity.py create mode 100644 homeassistant/components/motionmount/manifest.json create mode 100644 homeassistant/components/motionmount/number.py create mode 100644 homeassistant/components/motionmount/strings.json create mode 100644 tests/components/motionmount/__init__.py create mode 100644 tests/components/motionmount/conftest.py create mode 100644 tests/components/motionmount/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index adb0458d6f6..7e1a3287a19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -757,6 +757,9 @@ omit = homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py + homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/entity.py + homeassistant/components/motionmount/number.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py diff --git a/.strict-typing b/.strict-typing index 01b88ec2781..d23da1c2fd2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -238,6 +238,7 @@ homeassistant.components.modbus.* homeassistant.components.modem_callerid.* homeassistant.components.moon.* homeassistant.components.mopeka.* +homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* homeassistant.components.nam.* diff --git a/CODEOWNERS b/CODEOWNERS index 1ed96218424..05edd2c2b84 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -809,6 +809,8 @@ build.json @home-assistant/supervisor /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy +/homeassistant/components/motionmount/ @RJPoelstra +/tests/components/motionmount/ @RJPoelstra /homeassistant/components/mqtt/ @emontnemery @jbouwh /tests/components/mqtt/ @emontnemery @jbouwh /homeassistant/components/msteams/ @peroyvind diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py new file mode 100644 index 00000000000..8baceb104c3 --- /dev/null +++ b/homeassistant/components/motionmount/__init__.py @@ -0,0 +1,61 @@ +"""The Vogel's MotionMount integration.""" +from __future__ import annotations + +import socket + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vogel's MotionMount from a config entry.""" + + host = entry.data[CONF_HOST] + + # Create API instance + mm = motionmount.MotionMount(host, entry.data[CONF_PORT]) + + # Validate the API connection + try: + await mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror) as ex: + raise ConfigEntryNotReady(f"Failed to connect to {host}") from ex + + found_mac = format_mac(mm.mac.hex()) + if found_mac not in (EMPTY_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. + await mm.disconnect() + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + ) + + # Store an API object for your platforms to access + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + + 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): + mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + await mm.disconnect() + + return unload_ok diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py new file mode 100644 index 00000000000..a593b30201e --- /dev/null +++ b/homeassistant/components/motionmount/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Vogel's MotionMount.""" +import logging +import socket +from typing import Any + +import motionmount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +_LOGGER = logging.getLogger(__name__) + + +# A MotionMount can be in four states: +# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac +# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails +# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC) +# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac +# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount +class MotionMountFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Vogel's MotionMount config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Set up the instance.""" + self.discovery_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + info = {} + try: + info = await self._validate_input(user_input) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + # This is most likely due to missing support for the mac address property + # Abort if the handler has config entries already + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + # Otherwise we try to continue with the generic uid + info[CONF_UUID] = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + # If the device mac is valid we use it, otherwise we use the default id + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + else: + unique_id = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + name = info.get(CONF_NAME, user_input[CONF_HOST]) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + # Extract information from discovery + host = discovery_info.hostname + port = discovery_info.port + zctype = discovery_info.type + name = discovery_info.name.removesuffix(f".{zctype}") + unique_id = discovery_info.properties.get("mac") + + self.discovery_info.update( + { + CONF_HOST: host, + CONF_PORT: port, + CONF_NAME: name, + } + ) + + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + # Avoid probing devices that already have an entry + self._async_abort_entries_match({CONF_HOST: host}) + + self.context.update({"title_placeholders": {"name": name}}) + + try: + info = await self._validate_input(self.discovery_info) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + info = {} + # We continue as we want to be able to connect with older FW that does not support MAC address + + # If the device supplied as with a valid MAC we use that + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + await self._async_handle_discovery_without_unique_id() + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], + data=self.discovery_info, + ) + + async def _validate_input(self, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + finally: + await mm.disconnect() + + return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + + def _show_setup_form(self, errors: dict[str, str] | None = None) -> FlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=23): int, + } + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py new file mode 100644 index 00000000000..92045193ad6 --- /dev/null +++ b/homeassistant/components/motionmount/const.py @@ -0,0 +1,5 @@ +"""Constants for the Vogel's MotionMount integration.""" + +DOMAIN = "motionmount" + +EMPTY_MAC = "00:00:00:00:00:00" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py new file mode 100644 index 00000000000..c3f7c9c9358 --- /dev/null +++ b/homeassistant/components/motionmount/entity.py @@ -0,0 +1,53 @@ +"""Support for MotionMount sensors.""" + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, EMPTY_MAC + + +class MotionMountEntity(Entity): + """Representation of a MotionMount entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize general MotionMount entity.""" + self.mm = mm + mac = format_mac(mm.mac.hex()) + + # Create a base unique id + if mac == EMPTY_MAC: + self._base_unique_id = config_entry.entry_id + else: + self._base_unique_id = mac + + # Set device info + self._attr_device_info = DeviceInfo( + name=mm.name, + manufacturer="Vogel's", + model="TVM 7675", + ) + + if mac == EMPTY_MAC: + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, config_entry.entry_id)} + else: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac) + } + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.mm.add_listener(self.async_write_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Remove register state change callback.""" + self.mm.remove_listener(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json new file mode 100644 index 00000000000..bfe7e21fce9 --- /dev/null +++ b/homeassistant/components/motionmount/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "motionmount", + "name": "Vogel's MotionMount", + "codeowners": ["@RJPoelstra"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/motionmount", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["python-MotionMount==0.3.1"], + "zeroconf": ["_tvm._tcp.local."] +} diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py new file mode 100644 index 00000000000..476e14c3a82 --- /dev/null +++ b/homeassistant/components/motionmount/number.py @@ -0,0 +1,71 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ( + MotionMountExtension(mm, entry), + MotionMountTurn(mm, entry), + ) + ) + + +class MotionMountExtension(MotionMountEntity, NumberEntity): + """The target extension position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = 0 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_extension" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Extension number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-extension" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.extension or 0) + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for extension.""" + await self.mm.set_extension(int(value)) + + +class MotionMountTurn(MotionMountEntity, NumberEntity): + """The target turn position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = -100 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_turn" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Turn number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-turn" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.turn or 0) * -1 + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for turn.""" + await self.mm.set_turn(int(value * -1)) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json new file mode 100644 index 00000000000..00a409f3058 --- /dev/null +++ b/homeassistant/components/motionmount/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Link your MotionMount", + "description": "Set up your MotionMount to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name}?", + "title": "Discovered MotionMount" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "time_out": "Failed to connect due to a time out.", + "not_connected": "Failed to connect.", + "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + } + }, + "entity": { + "number": { + "motionmount_extension": { + "name": "Extension" + }, + "motionmount_turn": { + "name": "Turn" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index df69af184ac..47f7087fcc8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -304,6 +304,7 @@ FLOWS = { "mopeka", "motion_blinds", "motioneye", + "motionmount", "mqtt", "mullvad", "mutesync", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0a3229d73b2..db385c5eff9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3613,6 +3613,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "motionmount": { + "name": "Vogel's MotionMount", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "mpd": { "name": "Music Player Daemon (MPD)", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 78f21f90b5e..fea1d4ec889 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -705,6 +705,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_tvm._tcp.local.": [ + { + "domain": "motionmount", + }, + ], "_uzg-01._tcp.local.": [ { "domain": "zha", diff --git a/mypy.ini b/mypy.ini index bd0e4f76b85..45ad5207078 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2141,6 +2141,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.motionmount.*] +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.mqtt.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4ba4b8ab03e..bcf8a8bd6a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2132,6 +2132,9 @@ pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9623f502472..739cc1db31b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,6 +1622,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py new file mode 100644 index 00000000000..da6fbae32a3 --- /dev/null +++ b/tests/components/motionmount/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the Vogel's MotionMount integration.""" + +from ipaddress import ip_address + +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PORT + +HOST = "192.168.1.31" +PORT = 23 + +TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." + +ZEROCONF_NAME = "My MotionMount" +ZEROCONF_HOST = HOST +ZEROCONF_HOSTNAME = "MMF8A55F.local." +ZEROCONF_PORT = PORT +ZEROCONF_MAC = "c4:dd:57:f8:a5:5f" + +MOCK_USER_INPUT = { + CONF_HOST: HOST, + CONF_PORT: PORT, +} + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"txtvers": "1", "model": "TVM 7675"}, +) + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"mac": ZEROCONF_MAC, "txtvers": "2", "model": "TVM 7675"}, +) diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py new file mode 100644 index 00000000000..8a838dac83c --- /dev/null +++ b/tests/components/motionmount/conftest.py @@ -0,0 +1,44 @@ +"""Fixtures for Vogel's MotionMount integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=ZEROCONF_MAC, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.motionmount.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked MotionMount config flow.""" + + with patch( + "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + client = motionmount_mock.return_value + yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py new file mode 100644 index 00000000000..aa7ea73b577 --- /dev/null +++ b/tests/components/motionmount/test_config_flow.py @@ -0,0 +1,488 @@ +"""Tests for the Vogel's MotionMount config flow.""" +import dataclasses +import socket +from unittest.mock import MagicMock, PropertyMock + +import motionmount +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, + PORT, + ZEROCONF_HOSTNAME, + ZEROCONF_MAC, + ZEROCONF_NAME, +) + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_user_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when an invalid hostname is provided.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_timeout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_user_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_user_response_error_single_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_user_response_error_multi_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_response_error_multi_device_new_ce_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_timout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_zeroconf_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_show_zeroconf_form_old_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_old_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC From b4f8fe8d4d8a65b1420009b2b95bae07fe585f21 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 22 Dec 2023 21:07:47 +1000 Subject: [PATCH 617/927] Add media player platform to Tessie (#106214) * Add media platform * Add more props * Fix platform filename * Working * Add a test * Update test and fixture * Refactor media player properties to handle null values * Add comments * add more assertions * Fix test docstring * Use walrus instead. Co-authored-by: Joost Lekkerkerker * Test when media player is idle * Fix tests * Remove None type from volume_level Co-authored-by: Joost Lekkerkerker * Return media position only when a media duration is > 0 * Remove impossible None type * Add snapshot and freezer --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tessie/__init__.py | 1 + .../components/tessie/media_player.py | 109 ++++++++++++++++++ tests/components/tessie/fixtures/online.json | 14 +-- .../components/tessie/fixtures/vehicles.json | 2 +- .../tessie/snapshots/test_media_player.ambr | 61 ++++++++++ tests/components/tessie/test_media_player.py | 46 ++++++++ 6 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/tessie/media_player.py create mode 100644 tests/components/tessie/snapshots/test_media_player.ambr create mode 100644 tests/components/tessie/test_media_player.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e7fb41b0788..f344cef2484 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.COVER, Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py new file mode 100644 index 00000000000..ffbb6619668 --- /dev/null +++ b/homeassistant/components/tessie/media_player.py @@ -0,0 +1,109 @@ +"""Media Player platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Media platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieMediaEntity(coordinator) for coordinator in coordinators) + + +class TessieMediaEntity(TessieEntity, MediaPlayerEntity): + """Vehicle Location Media Class.""" + + _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the media player entity.""" + super().__init__(coordinator, "media") + + @property + def state(self) -> MediaPlayerState: + """State of the player.""" + return STATES.get( + self.get("vehicle_state_media_info_media_playback_status"), + MediaPlayerState.OFF, + ) + + @property + def volume_level(self) -> float: + """Volume level of the media player (0..1).""" + return self.get("vehicle_state_media_info_audio_volume", 0) / self.get( + "vehicle_state_media_info_audio_volume_max", 10.333333 + ) + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + return duration / 1000 + return None + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + # Return media position only when a media duration is > 0 + if self.get("vehicle_state_media_info_now_playing_duration"): + return self.get("vehicle_state_media_info_now_playing_elapsed") / 1000 + return None + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if title := self.get("vehicle_state_media_info_now_playing_title"): + return title + return None + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if artist := self.get("vehicle_state_media_info_now_playing_artist"): + return artist + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if album := self.get("vehicle_state_media_info_now_playing_album"): + return album + return None + + @property + def media_playlist(self) -> str | None: + """Title of Playlist currently playing.""" + if playlist := self.get("vehicle_state_media_info_now_playing_station"): + return playlist + return None + + @property + def source(self) -> str | None: + """Name of the current input source.""" + if source := self.get("vehicle_state_media_info_now_playing_source"): + return source + return None diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json index 8fbab1ab948..863e9bca783 100644 --- a/tests/components/tessie/fixtures/online.json +++ b/tests/components/tessie/fixtures/online.json @@ -204,14 +204,14 @@ "audio_volume": 2.3333, "audio_volume_increment": 0.333333, "audio_volume_max": 10.333333, - "media_playback_status": "Stopped", - "now_playing_album": "", - "now_playing_artist": "", - "now_playing_duration": 0, - "now_playing_elapsed": 0, + "media_playback_status": "Playing", + "now_playing_album": "Album", + "now_playing_artist": "Artist", + "now_playing_duration": 60000, + "now_playing_elapsed": 30000, "now_playing_source": "Spotify", - "now_playing_station": "", - "now_playing_title": "" + "now_playing_station": "Playlist", + "now_playing_title": "Song" }, "media_state": { "remote_control_enabled": false diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index 05b19261c36..9d2305a04cd 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -222,7 +222,7 @@ "now_playing_artist": "", "now_playing_duration": 0, "now_playing_elapsed": 0, - "now_playing_source": "Spotify", + "now_playing_source": "", "now_playing_station": "", "now_playing_title": "" }, diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..8dc07797d6c --- /dev/null +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_media_player_idle + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_idle.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_playing + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py new file mode 100644 index 00000000000..8e3e339b560 --- /dev/null +++ b/tests/components/tessie/test_media_player.py @@ -0,0 +1,46 @@ +"""Test the Tessie media player platform.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_STATE_OF_ALL_VEHICLES, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + +MEDIA_INFO_1 = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"]["vehicle_state"][ + "media_info" +] +MEDIA_INFO_2 = TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["media_info"] + + +async def test_media_player_idle( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion +) -> None: + """Tests that the media player entity is correct when idle.""" + + assert len(hass.states.async_all("media_player")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("media_player")) == 1 + + state = hass.states.get("media_player.test") + assert state == snapshot + + # Trigger coordinator refresh since it has a different fixture. + freezer.tick(WAIT) + async_fire_time_changed(hass) + + state = hass.states.get("media_player.test") + assert state == snapshot From f536bc1d0c3ab93539d7842eb6ae3323a182e061 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Dec 2023 12:08:06 +0100 Subject: [PATCH 618/927] Add valve support to Amazon Alexa (#106053) Add valve platform to Amazon Alexa --- .../components/alexa/capabilities.py | 110 ++++ homeassistant/components/alexa/entities.py | 26 + homeassistant/components/alexa/handlers.py | 59 +- tests/components/alexa/test_capabilities.py | 138 +++++ tests/components/alexa/test_smart_home.py | 553 +++++++++++++++++- 5 files changed, 876 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 955502c8149..502912ee8de 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ from homeassistant.components import ( number, timer, vacuum, + valve, water_heater, ) from homeassistant.components.alarm_control_panel import ( @@ -1444,6 +1445,19 @@ class AlexaModeController(AlexaCapability): ): return f"{cover.ATTR_POSITION}.{mode}" + # Valve position state + if self.instance == f"{valve.DOMAIN}.state": + # Return state instead of position when using ModeController. + state = self.entity.state + if state in ( + valve.STATE_OPEN, + valve.STATE_OPENING, + valve.STATE_CLOSED, + valve.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"state.{state}" + return None def configuration(self) -> dict[str, Any] | None: @@ -1540,6 +1554,32 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Valve position resources + if self.instance == f"{valve.DOMAIN}.state": + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._resource = AlexaModeResource( + ["Preset", AlexaGlobalCatalog.SETTING_PRESET], False + ) + modes = 0 + if supported_features & valve.ValveEntityFeature.OPEN: + self._resource.add_mode( + f"state.{valve.STATE_OPEN}", + ["Open", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + if supported_features & valve.ValveEntityFeature.CLOSE: + self._resource.add_mode( + f"state.{valve.STATE_CLOSED}", + ["Closed", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + + # Alexa requiers at least 2 modes + if modes == 1: + self._resource.add_mode(f"state.{PRESET_MODE_NA}", [PRESET_MODE_NA]) + + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1578,6 +1618,34 @@ class AlexaModeController(AlexaCapability): return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.state": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"state.{valve.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"state.{valve.STATE_OPEN}", + ) + + self._semantics.add_action_to_directive( + close_labels, + "SetMode", + {"mode": f"state.{valve.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + open_labels, + "SetMode", + {"mode": f"state.{valve.STATE_OPEN}"}, + ) + + return self._semantics.serialize_semantics() + return None @@ -1691,6 +1759,10 @@ class AlexaRangeController(AlexaCapability): ) return speed_index + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION) + return None def configuration(self) -> dict[str, Any] | None: @@ -1814,6 +1886,17 @@ class AlexaRangeController(AlexaCapability): return self._resource.serialize_capability_resources() + # Valve Position Resources + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Opening", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1890,6 +1973,25 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + + self._semantics.add_action_to_directive( + close_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + open_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None @@ -1963,6 +2065,10 @@ class AlexaToggleController(AlexaCapability): is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) return "ON" if is_on else "OFF" + # Stop Valve + if self.instance == f"{valve.DOMAIN}.stop": + return "OFF" + return None def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @@ -1975,6 +2081,10 @@ class AlexaToggleController(AlexaCapability): ) return self._resource.serialize_capability_resources() + if self.instance == f"{valve.DOMAIN}.stop": + self._resource = AlexaCapabilityResource(["Stop"]) + return self._resource.serialize_capability_resources() + return {} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 2f89058514b..d0e265b8454 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,7 @@ from homeassistant.components import ( switch, timer, vacuum, + valve, water_heater, ) from homeassistant.const import ( @@ -976,6 +977,31 @@ class VacuumCapabilities(AlexaEntity): yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(valve.DOMAIN) +class ValveCapabilities(AlexaEntity): + """Class to represent Valve capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability, None, None]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & valve.ValveEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}" + ) + elif supported & ( + valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN + ): + yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state") + if supported & valve.ValveEntityFeature.STOP: + yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(camera.DOMAIN) class CameraCapabilities(AlexaEntity): """Class to represent Camera capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8e81cf1a2c6..5613da52db5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,7 @@ from homeassistant.components import ( number, timer, vacuum, + valve, water_heater, ) from homeassistant.const import ( @@ -1216,6 +1217,15 @@ async def async_api_set_mode( elif position == "custom": service = cover.SERVICE_STOP_COVER + # Valve position state + elif instance == f"{valve.DOMAIN}.state": + position = mode.split(".")[1] + + if position == valve.STATE_CLOSED: + service = valve.SERVICE_CLOSE_VALVE + elif position == valve.STATE_OPEN: + service = valve.SERVICE_OPEN_VALVE + if not service: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1266,15 +1276,22 @@ async def async_api_toggle_on( instance = directive.instance domain = entity.domain - # Fan Oscillating - if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": - raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + data: dict[str, Any] - service = fan.SERVICE_OSCILLATE - data: dict[str, Any] = { - ATTR_ENTITY_ID: entity.entity_id, - fan.ATTR_OSCILLATING: True, - } + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: True, + } + elif instance == f"{valve.DOMAIN}.stop": + service = valve.SERVICE_STOP_VALVE + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) await hass.services.async_call( domain, service, data, blocking=False, context=context @@ -1417,6 +1434,17 @@ async def async_api_set_range( data[vacuum.ATTR_FAN_SPEED] = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_value = int(range_value) + if supported & valve.ValveEntityFeature.CLOSE and range_value == 0: + service = valve.SERVICE_CLOSE_VALVE + elif supported & valve.ValveEntityFeature.OPEN and range_value == 100: + service = valve.SERVICE_OPEN_VALVE + else: + service = valve.SERVICE_SET_VALVE_POSITION + data[valve.ATTR_POSITION] = range_value + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1562,6 +1590,21 @@ async def async_api_adjust_range( ) data[vacuum.ATTR_FAN_SPEED] = response_value = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = valve.SERVICE_SET_VALVE_POSITION + if not (current := entity.attributes.get(valve.ATTR_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = valve.SERVICE_OPEN_VALVE + elif position == 0: + service = valve.SERVICE_CLOSE_VALVE + else: + data[valve.ATTR_POSITION] = position + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 7c39e34ac38..b83bdb794a8 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,7 @@ from homeassistant.components.alexa import smart_home from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import ( ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, @@ -653,6 +654,143 @@ async def test_report_cover_range_value(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.RangeController", "rangeValue", 0) +async def test_report_valve_range_value(hass: HomeAssistant) -> None: + """Test RangeController reports valve position correctly.""" + all_valve_features = ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION + ) + hass.states.async_set( + "valve.fully_open", + "open", + { + "friendly_name": "Fully open valve", + "current_position": 100, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.half_open", + "open", + { + "friendly_name": "Half open valve", + "current_position": 50, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.closed", + "closed", + { + "friendly_name": "Closed valve", + "current_position": 0, + "supported_features": all_valve_features, + }, + ) + + properties = await reported_properties(hass, "valve.fully_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "valve.half_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 50) + + properties = await reported_properties(hass, "valve.closed") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + + +@pytest.mark.parametrize( + ( + "supported_features", + "has_mode_controller", + "has_range_controller", + "has_toggle_controller", + ), + [ + (ValveEntityFeature(0), False, False, False), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + True, + False, + True, + ), + ( + ValveEntityFeature.OPEN, + True, + False, + False, + ), + ( + ValveEntityFeature.CLOSE, + True, + False, + False, + ), + ( + ValveEntityFeature.STOP, + False, + False, + True, + ), + ( + ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ( + ValveEntityFeature.STOP | ValveEntityFeature.SET_POSITION, + False, + True, + True, + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ], +) +async def test_report_valve_controllers( + hass: HomeAssistant, + supported_features: ValveEntityFeature, + has_mode_controller: bool, + has_range_controller: bool, + has_toggle_controller: bool, +) -> None: + """Test valve controllers are reported correctly.""" + hass.states.async_set( + "valve.custom", + "opening", + { + "friendly_name": "Custom valve", + "current_position": 0, + "supported_features": supported_features, + }, + ) + + properties = await reported_properties(hass, "valve.custom") + + if has_mode_controller: + properties.assert_equal("Alexa.ModeController", "mode", "state.opening") + else: + properties.assert_not_has_property("Alexa.ModeController", "mode") + if has_range_controller: + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + else: + properties.assert_not_has_property("Alexa.RangeController", "rangeValue") + if has_toggle_controller: + properties.assert_equal("Alexa.ToggleController", "toggleState", "OFF") + else: + properties.assert_not_has_property("Alexa.ToggleController", "toggleState") + + async def test_report_climate_state(hass: HomeAssistant) -> None: """Test ThermostatController reports state correctly.""" for auto_modes in (HVACMode.AUTO, HVACMode.HEAT_COOL): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index d025b1586f5..ff8fef43a66 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -9,8 +9,14 @@ import homeassistant.components.camera as camera from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import SERVICE_STOP_VALVE, ValveEntityFeature from homeassistant.config import async_process_ha_core_config -from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -156,7 +162,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): capabilities = endpoint["capabilities"] supported = {feature["interface"] for feature in capabilities} - assert supported == set(interfaces) + assert supported == {interface for interface in interfaces if interface is not None} return capabilities @@ -2069,6 +2075,216 @@ async def test_cover_position( assert properties["value"] == position +@pytest.mark.parametrize( + ( + "position", + "position_attr_in_service_call", + "supported_features", + "service_call", + "has_toggle_controller", + ), + [ + ( + 30, + 30, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 0, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.close_valve", + False, + ), + ( + 99, + 99, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ( + 100, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.open_valve", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.OPEN, + "valve.set_valve_position", + False, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ], + ids=[ + "position_30_open_close_stop", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_60_stop_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_valve_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, + has_toggle_controller: bool, +) -> None: + """Test cover discovery and position using rangeController.""" + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController" if has_toggle_controller else None, + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "valve#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="valve.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + async def test_cover_position_range( hass: HomeAssistant, ) -> None: @@ -2186,6 +2402,208 @@ async def test_cover_position_range( ) +async def test_valve_position_range( + hass: HomeAssistant, +) -> None: + """Test valve discovery and position range using rangeController. + + Also tests an invalid valve position being handled correctly. + """ + + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": 15, + "position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.open_valve", + hass, + payload={"rangeValueDelta": 101, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 100 + assert call.service == SERVICE_OPEN_VALVE + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.close_valve", + hass, + payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 0 + assert call.service == SERVICE_CLOSE_VALVE + + await assert_range_changes( + hass, + [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)], + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.set_valve_position", + "position", + instance="valve.position", + ) + + +@pytest.mark.parametrize( + ("supported_features", "state_controller"), + [ + ( + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "Alexa.RangeController", + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "Alexa.ModeController", + ), + ], +) +async def test_stop_valve( + hass: HomeAssistant, supported_features: ValveEntityFeature, state_controller: str +) -> None: + """Test stop valve ToggleController.""" + device = ( + "valve.test", + "opening", + { + "friendly_name": "Test valve", + "supported_features": supported_features, + "current_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve" + capabilities = assert_endpoint_capabilities( + appliance, + state_controller, + "Alexa.ToggleController", + "Alexa.EndpointHealth", + "Alexa", + ) + + toggle_capability = get_capability(capabilities, "Alexa.ToggleController") + assert toggle_capability is not None + assert toggle_capability["instance"] == "valve.stop" + + properties = toggle_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "toggleState"} in properties["supported"] + + capability_resources = toggle_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Stop", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOn", + "valve#test", + "valve.stop_valve", + hass, + payload={}, + instance="valve.stop", + ) + assert call.data["entity_id"] == "valve.test" + assert call.service == SERVICE_STOP_VALVE + + async def assert_percentage_changes( hass, adjustments, namespace, name, endpoint, parameter, service, changed_parameter ): @@ -3667,6 +4085,137 @@ async def test_cover_position_mode(hass: HomeAssistant) -> None: assert properties["value"] == "position.custom" +async def test_valve_position_mode(hass: HomeAssistant) -> None: + """Test valve discovery and position using modeController.""" + device = ( + "valve.test_mode", + "open", + { + "friendly_name": "Test valve mode", + "device_class": "water", + "supported_features": ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_mode" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve mode" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "valve.state" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Preset", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "state.open", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Open", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + assert { + "value": "state.closed", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Closed", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + + # Assert for Position Semantics + position_semantics = mode_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetMode", "payload": {"mode": "state.closed"}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetMode", "payload": {"mode": "state.open"}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": "state.closed", + } in position_state_mappings + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Open"], + "value": "state.open", + } in position_state_mappings + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.close_valve", + hass, + payload={"mode": "state.closed"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.closed" + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.open_valve", + hass, + payload={"mode": "state.open"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.open" + + async def test_image_processing(hass: HomeAssistant) -> None: """Test image_processing discovery as event detection.""" device = ( From 102c7f1959eee530ba76d982c5ade42afa52908b Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:18:32 +0800 Subject: [PATCH 619/927] Add Refoss integration (#100573) * refoss * refoss * refoss * refoss * refoss modify * ip * 8.22 * format * format * format * bugfix * test * test * test * test * test * test * 9.1 * refosss * refoss * refoss * refoss * refoss * refoss * refoss * refoss * test * requirements_test_all.txt * codeowners * refoss * Review feedback repair * strings * refoss * refoss * refoss * 1.1.1 * 1.1.2 * refoss * refoss * refoss.1.1.7 * refoss-gree * 1.1.7 * test * refoss * test refoss * test refoss * refoss-test * refoss * refoss * test * test * refoss * CODEOWNERS * fix * Update homeassistant/components/refoss/__init__.py --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 6 + CODEOWNERS | 2 + homeassistant/components/refoss/__init__.py | 56 +++++++++ homeassistant/components/refoss/bridge.py | 45 ++++++++ .../components/refoss/config_flow.py | 20 ++++ homeassistant/components/refoss/const.py | 20 ++++ .../components/refoss/coordinator.py | 39 +++++++ homeassistant/components/refoss/entity.py | 31 +++++ homeassistant/components/refoss/manifest.json | 9 ++ homeassistant/components/refoss/strings.json | 13 +++ homeassistant/components/refoss/switch.py | 69 +++++++++++ homeassistant/components/refoss/util.py | 15 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/refoss/__init__.py | 107 ++++++++++++++++++ tests/components/refoss/conftest.py | 14 +++ tests/components/refoss/test_config_flow.py | 65 +++++++++++ 19 files changed, 524 insertions(+) create mode 100644 homeassistant/components/refoss/__init__.py create mode 100644 homeassistant/components/refoss/bridge.py create mode 100644 homeassistant/components/refoss/config_flow.py create mode 100644 homeassistant/components/refoss/const.py create mode 100644 homeassistant/components/refoss/coordinator.py create mode 100644 homeassistant/components/refoss/entity.py create mode 100644 homeassistant/components/refoss/manifest.json create mode 100644 homeassistant/components/refoss/strings.json create mode 100644 homeassistant/components/refoss/switch.py create mode 100644 homeassistant/components/refoss/util.py create mode 100644 tests/components/refoss/__init__.py create mode 100644 tests/components/refoss/conftest.py create mode 100644 tests/components/refoss/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7e1a3287a19..528ec2e3dac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1035,6 +1035,12 @@ omit = homeassistant/components/recorder/repack.py homeassistant/components/recswitch/switch.py homeassistant/components/reddit/sensor.py + homeassistant/components/refoss/__init__.py + homeassistant/components/refoss/bridge.py + homeassistant/components/refoss/coordinator.py + homeassistant/components/refoss/entity.py + homeassistant/components/refoss/switch.py + homeassistant/components/refoss/util.py homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* diff --git a/CODEOWNERS b/CODEOWNERS index 05edd2c2b84..7a8b3ea1885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1056,6 +1056,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/refoss/ @ashionky +/tests/components/refoss/ @ashionky /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py new file mode 100644 index 00000000000..d83ca17dd6b --- /dev/null +++ b/homeassistant/components/refoss/__init__.py @@ -0,0 +1,56 @@ +"""Refoss devices platform loader.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .bridge import DiscoveryService +from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .util import refoss_discovery_server + +PLATFORMS: Final = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Refoss from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + discover = await refoss_discovery_server(hass) + refoss_discovery = DiscoveryService(hass, discover) + hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_scan_update(_=None): + await refoss_discovery.discovery.broadcast_msg() + + await _async_scan_update() + + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: + refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] + refoss_discovery.discovery.clean_up() + hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(COORDINATORS) + + return unload_ok diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py new file mode 100644 index 00000000000..888179e8a7c --- /dev/null +++ b/homeassistant/components/refoss/bridge.py @@ -0,0 +1,45 @@ +"""Refoss integration.""" +from __future__ import annotations + +from refoss_ha.device import DeviceInfo +from refoss_ha.device_manager import async_build_base_device +from refoss_ha.discovery import Discovery, Listener + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .coordinator import RefossDataUpdateCoordinator + + +class DiscoveryService(Listener): + """Discovery event handler for refoss devices.""" + + def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + """Init discovery service.""" + self.hass = hass + + self.discovery = discovery + self.discovery.add_listener(self) + + hass.data[DOMAIN].setdefault(COORDINATORS, []) + + async def device_found(self, device_info: DeviceInfo) -> None: + """Handle new device found on the network.""" + + device = await async_build_base_device(device_info) + if device is None: + return None + + coordo = RefossDataUpdateCoordinator(self.hass, device) + self.hass.data[DOMAIN][COORDINATORS].append(coordo) + await coordo.async_refresh() + + async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.inner_ip = device_info.inner_ip + await coordinator.async_refresh() diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py new file mode 100644 index 00000000000..fe33cefc1bd --- /dev/null +++ b/homeassistant/components/refoss/config_flow.py @@ -0,0 +1,20 @@ +"""Config Flow for Refoss integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import DISCOVERY_TIMEOUT, DOMAIN +from .util import refoss_discovery_server + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + refoss_discovery = await refoss_discovery_server(hass) + devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT) + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow(DOMAIN, "Refoss", _async_has_devices) diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py new file mode 100644 index 00000000000..dd11921c75e --- /dev/null +++ b/homeassistant/components/refoss/const.py @@ -0,0 +1,20 @@ +"""const.""" +from __future__ import annotations + +from logging import Logger, getLogger + +_LOGGER: Logger = getLogger(__package__) + +COORDINATORS = "coordinators" + +DATA_DISCOVERY_SERVICE = "refoss_discovery" + +DISCOVERY_SCAN_INTERVAL = 30 +DISCOVERY_TIMEOUT = 8 +DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered" +DISPATCHERS = "dispatchers" + +DOMAIN = "refoss" +COORDINATOR = "coordinator" + +MAX_ERRORS = 2 diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py new file mode 100644 index 00000000000..a542f0e1ae8 --- /dev/null +++ b/homeassistant/components/refoss/coordinator.py @@ -0,0 +1,39 @@ +"""Helper and coordinator for refoss.""" +from __future__ import annotations + +from datetime import timedelta + +from refoss_ha.controller.device import BaseDevice +from refoss_ha.exceptions import DeviceTimeoutError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import _LOGGER, DOMAIN, MAX_ERRORS + + +class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Manages polling for state changes from the device.""" + + def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{device.device_info.dev_name}", + update_interval=timedelta(seconds=15), + ) + self.device = device + self._error_count = 0 + + async def _async_update_data(self) -> None: + """Update the state of the device.""" + try: + await self.device.async_handle_update() + self.last_update_success = True + self._error_count = 0 + except DeviceTimeoutError: + self._error_count += 1 + + if self._error_count >= MAX_ERRORS: + self.last_update_success = False diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py new file mode 100644 index 00000000000..d3425974bb1 --- /dev/null +++ b/homeassistant/components/refoss/entity.py @@ -0,0 +1,31 @@ +"""Entity object for shared properties of Refoss entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import RefossDataUpdateCoordinator +from .const import DOMAIN + + +class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): + """Refoss entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RefossDataUpdateCoordinator, channel: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + mac = coordinator.device.mac + self.channel_id = channel + if channel == 0: + self._attr_name = None + else: + self._attr_name = str(channel) + + self._attr_unique_id = f"{mac}_{channel}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, + manufacturer="Refoss", + name=coordinator.device.dev_name, + ) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json new file mode 100644 index 00000000000..8e5b3864bcc --- /dev/null +++ b/homeassistant/components/refoss/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "refoss", + "name": "Refoss", + "codeowners": ["@ashionky"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/refoss", + "iot_class": "local_polling", + "requirements": ["refoss-ha==1.2.0"] +} diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/refoss/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py new file mode 100644 index 00000000000..c51f166059e --- /dev/null +++ b/homeassistant/components/refoss/switch.py @@ -0,0 +1,69 @@ +"""Switch for Refoss.""" + +from __future__ import annotations + +from typing import Any + +from refoss_ha.controller.toggle import ToggleXMix + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .entity import RefossEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Refoss device from a config entry.""" + + @callback + def init_device(coordinator): + """Register the device.""" + device = coordinator.device + if not isinstance(device, ToggleXMix): + return + + new_entities = [] + for channel in device.channels: + entity = RefossSwitch(coordinator=coordinator, channel=channel) + new_entities.append(entity) + + async_add_entities(new_entities) + + for coordinator in hass.data[DOMAIN][COORDINATORS]: + init_device(coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) + ) + + +class RefossSwitch(RefossEntity, SwitchEntity): + """Refoss Switch Device.""" + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.coordinator.device.is_on(channel=self.channel_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.device.async_turn_on(self.channel_id) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.device.async_turn_off(self.channel_id) + self.async_write_ha_state() + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the switch.""" + await self.coordinator.device.async_toggle(channel=self.channel_id) + self.async_write_ha_state() diff --git a/homeassistant/components/refoss/util.py b/homeassistant/components/refoss/util.py new file mode 100644 index 00000000000..cd589022d73 --- /dev/null +++ b/homeassistant/components/refoss/util.py @@ -0,0 +1,15 @@ +"""Refoss helpers functions.""" +from __future__ import annotations + +from refoss_ha.discovery import Discovery + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import singleton + + +@singleton.singleton("refoss_discovery_server") +async def refoss_discovery_server(hass: HomeAssistant) -> Discovery: + """Get refoss Discovery server.""" + discovery_server = Discovery() + await discovery_server.initialize() + return discovery_server diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 47f7087fcc8..1deeae819a0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -398,6 +398,7 @@ FLOWS = { "rapt_ble", "rdw", "recollect_waste", + "refoss", "renault", "renson", "reolink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index db385c5eff9..9479153dd0d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4746,6 +4746,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "refoss": { + "name": "Refoss", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index bcf8a8bd6a0..b903bc7daca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2355,6 +2355,9 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 739cc1db31b..88b9fa3e8d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1773,6 +1773,9 @@ radiotherm==2.1.0 # homeassistant.components.rapt_ble rapt-ble==0.1.2 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 diff --git a/tests/components/refoss/__init__.py b/tests/components/refoss/__init__.py new file mode 100644 index 00000000000..34df1b41714 --- /dev/null +++ b/tests/components/refoss/__init__.py @@ -0,0 +1,107 @@ +"""Common helpers for refoss test cases.""" +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +from refoss_ha.discovery import Listener + +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class FakeDiscovery: + """Mock class replacing refoss device discovery.""" + + def __init__(self) -> None: + """Initialize the class.""" + self.mock_devices = {"abc": build_device_mock()} + self.last_mock_infos = {} + self._listeners = [] + + def add_listener(self, listener: Listener) -> None: + """Add an event listener.""" + self._listeners.append(listener) + + async def initialize(self) -> None: + """Initialize socket server.""" + self.sock = Mock() + + async def broadcast_msg(self, wait_for: int = 0): + """Search for devices, return mocked data.""" + + mock_infos = self.mock_devices + last_mock_infos = self.last_mock_infos + + new_infos = [] + updated_infos = [] + + for info in mock_infos.values(): + uuid = info.uuid + if uuid not in last_mock_infos: + new_infos.append(info) + else: + last_info = self.last_mock_infos[uuid] + if info.inner_ip != last_info.inner_ip: + updated_infos.append(info) + + self.last_mock_infos = mock_infos + for listener in self._listeners: + [await listener.device_found(x) for x in new_infos] + [await listener.device_update(x) for x in updated_infos] + + if wait_for: + await asyncio.sleep(wait_for) + + return new_infos + + +def build_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock device object.""" + mock = Mock( + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + ) + return mock + + +def build_base_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock base device object.""" + mock = Mock( + device_info=build_device_mock(name=name, ip=ip, mac=mac), + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + async_handle_update=AsyncMock(), + async_turn_on=AsyncMock(), + async_turn_off=AsyncMock(), + async_toggle=AsyncMock(), + ) + mock.status = {0: True} + return mock + + +async def async_setup_refoss(hass: HomeAssistant) -> MockConfigEntry: + """Set up the refoss platform.""" + entry = MockConfigEntry(domain=DOMAIN) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/refoss/conftest.py b/tests/components/refoss/conftest.py new file mode 100644 index 00000000000..2fc695bbb2e --- /dev/null +++ b/tests/components/refoss/conftest.py @@ -0,0 +1,14 @@ +"""Pytest module configuration.""" +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.refoss.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/refoss/test_config_flow.py b/tests/components/refoss/test_config_flow.py new file mode 100644 index 00000000000..2a5842ffe46 --- /dev/null +++ b/tests/components/refoss/test_config_flow.py @@ -0,0 +1,65 @@ +"""Tests for the refoss Integration.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import FakeDiscovery, build_base_device_mock + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_sets_up( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up refoss.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ), patch( + "homeassistant.components.refoss.bridge.async_build_base_device", + return_value=build_base_device_mock(), + ), patch( + "homeassistant.components.refoss.switch.isinstance", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up Refoss no devices.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ) as discovery: + discovery.return_value.mock_devices = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 From 243ee2247bf5323713abfd643730d120e93b7ca0 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 22 Dec 2023 15:22:44 +0300 Subject: [PATCH 620/927] Add starline binary sensors (#105724) * Additional attributes for starline * Handsfree binary sensor * Sensors * Review --- .../components/starline/binary_sensor.py | 21 ++++++++++++++++++- homeassistant/components/starline/sensor.py | 2 ++ .../components/starline/strings.json | 9 ++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index bef724392b7..c0fe56df71e 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,7 +19,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="hbrake", translation_key="hand_brake", - device_class=BinarySensorDeviceClass.POWER, + icon="mdi:car-brake-parking", ), BinarySensorEntityDescription( key="hood", @@ -40,6 +41,24 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( translation_key="doors", device_class=BinarySensorDeviceClass.LOCK, ), + BinarySensorEntityDescription( + key="hfree", + translation_key="handsfree", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:hand-back-right", + ), + BinarySensorEntityDescription( + key="neutral", + translation_key="neutral", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-shift-pattern", + ), + BinarySensorEntityDescription( + key="arm_moving_pb", + translation_key="moving_ban", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-off", + ), ) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 4b787ae5212..603cceec222 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfLength, UnitOfTemperature, @@ -60,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="errors", translation_key="errors", icon="mdi:alert-octagon", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="mileage", diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 99cae9650ff..9631dbf7479 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -54,6 +54,15 @@ }, "doors": { "name": "Doors" + }, + "handsfree": { + "name": "Handsfree" + }, + "neutral": { + "name": "Programmable neutral" + }, + "moving_ban": { + "name": "Moving ban" } }, "device_tracker": { From fce1b6d24840f545474b17b2ac1391fc92f44a73 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Fri, 22 Dec 2023 08:24:08 -0500 Subject: [PATCH 621/927] Add DROP integration (#104319) * Add DROP integration * Remove all but one platform for first PR * Simplify initialization of hass.data[] structure * Remove unnecessary mnemonic 'DROP_' prefix from DOMAIN constants * Remove unnecessary whitespace * Clarify configuration 'confirm' step description * Remove unnecessary whitespace * Use device class where applicable * Remove unnecessary constructor and change its elements to class variables * Change base entity inheritance to CoordinatorEntity * Make sensor definitions more concise * Rename HA domain from drop to drop_connect * Remove underscores from class and function names * Remove duplicate temperature sensor * Change title capitalization * Refactor using SensorEntityDescription * Remove unnecessary intermediate dict layer * Remove generated translations file * Remove currently unused string values * Use constants in sensor definitions * Replace values with constants * Move translation keys * Remove unnecessary unique ID and config entry references * Clean up DROPEntity initialization * Clean up sensors * Rename vars and functions according to style * Remove redundant self references * Clean up DROPSensor initializer * Add missing state classes * Simplify detection of configured devices * Change entity identifiers to create device linkage * Move device_info to coordinator * Remove unnecessary properties * Correct hub device IDs * Remove redundant attribute * Replace optional UID with assert * Remove redundant attribute * Correct coordinator initialization * Fix mypy error * Move API functionality to 3rd party library * Abstract device to sensor map into a dict * Unsubscribe MQTT on unload * Move entity device information * Make type checking for mypy conditional * Bump dropmqttapi to 1.0.1 * Freeze dataclass to match parent class * Fix race condition in MQTT unsubscribe setup * Ensure unit tests begin with invalid MQTT state * Change unit tests to reflect device firmware * Move MQTT subscription out of the coordinator * Tidy up initializer * Move entirety of MQTT subscription out of the coordinator * Make drop_api a class property * Remove unnecessary type checks * Simplify some unit test asserts * Remove argument matching default * Add entity category to battery and cartridge life sensors --- CODEOWNERS | 2 + .../components/drop_connect/__init__.py | 66 ++++ .../components/drop_connect/config_flow.py | 98 ++++++ .../components/drop_connect/const.py | 25 ++ .../components/drop_connect/coordinator.py | 25 ++ .../components/drop_connect/entity.py | 53 +++ .../components/drop_connect/manifest.json | 11 + .../components/drop_connect/sensor.py | 285 ++++++++++++++++ .../components/drop_connect/strings.json | 30 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/mqtt.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/drop_connect/__init__.py | 1 + tests/components/drop_connect/common.py | 51 +++ tests/components/drop_connect/conftest.py | 177 ++++++++++ .../drop_connect/test_config_flow.py | 178 ++++++++++ .../drop_connect/test_coordinator.py | 74 ++++ tests/components/drop_connect/test_sensor.py | 319 ++++++++++++++++++ 20 files changed, 1411 insertions(+) create mode 100644 homeassistant/components/drop_connect/__init__.py create mode 100644 homeassistant/components/drop_connect/config_flow.py create mode 100644 homeassistant/components/drop_connect/const.py create mode 100644 homeassistant/components/drop_connect/coordinator.py create mode 100644 homeassistant/components/drop_connect/entity.py create mode 100644 homeassistant/components/drop_connect/manifest.json create mode 100644 homeassistant/components/drop_connect/sensor.py create mode 100644 homeassistant/components/drop_connect/strings.json create mode 100644 tests/components/drop_connect/__init__.py create mode 100644 tests/components/drop_connect/common.py create mode 100644 tests/components/drop_connect/conftest.py create mode 100644 tests/components/drop_connect/test_config_flow.py create mode 100644 tests/components/drop_connect/test_coordinator.py create mode 100644 tests/components/drop_connect/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 7a8b3ea1885..d5ae7848b15 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -297,6 +297,8 @@ build.json @home-assistant/supervisor /tests/components/dormakaba_dkey/ @emontnemery /homeassistant/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob +/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer +/tests/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py new file mode 100644 index 00000000000..45978a48d9a --- /dev/null +++ b/homeassistant/components/drop_connect/__init__.py @@ -0,0 +1,66 @@ +"""The drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback + +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up DROP from a config entry.""" + + # Make sure MQTT integration is enabled and the client is available. + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + + if TYPE_CHECKING: + assert config_entry.unique_id is not None + drop_data_coordinator = DROPDeviceDataUpdateCoordinator( + hass, config_entry.unique_id + ) + + @callback + def mqtt_callback(msg: ReceiveMessage) -> None: + """Pass MQTT payload to DROP API parser.""" + if drop_data_coordinator.drop_api.parse_drop_message( + msg.topic, msg.payload, msg.qos, msg.retain + ): + drop_data_coordinator.async_set_updated_data(None) + + config_entry.async_on_unload( + await mqtt.async_subscribe( + hass, config_entry.data[CONF_DATA_TOPIC], mqtt_callback + ) + ) + _LOGGER.debug( + "Entry %s (%s) subscribed to %s", + config_entry.unique_id, + config_entry.data[CONF_DEVICE_TYPE], + config_entry.data[CONF_DATA_TOPIC], + ) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + hass.data[DOMAIN].pop(config_entry.entry_id) + return unload_ok diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py new file mode 100644 index 00000000000..a2b93ad1da1 --- /dev/null +++ b/homeassistant/components/drop_connect/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from dropmqttapi.discovery import DropDiscovery + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DISCOVERY_TOPIC, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle DROP config flow.""" + + VERSION = 1 + + _drop_discovery: DropDiscovery | None = None + + async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: + """Handle a flow initialized by MQTT discovery.""" + + # Abort if the topic does not match our discovery topic or the payload is empty. + if ( + discovery_info.subscribed_topic != DISCOVERY_TOPIC + or not discovery_info.payload + ): + return self.async_abort(reason="invalid_discovery_info") + + self._drop_discovery = DropDiscovery(DOMAIN) + if not ( + await self._drop_discovery.parse_discovery( + discovery_info.topic, discovery_info.payload + ) + ): + return self.async_abort(reason="invalid_discovery_info") + existing_entry = await self.async_set_unique_id( + f"{self._drop_discovery.hub_id}_{self._drop_discovery.device_id}" + ) + if existing_entry is not None: + # Note: returning "invalid_discovery_info" here instead of "already_configured" + # allows discovery of additional device types. + return self.async_abort(reason="invalid_discovery_info") + + self.context.update({"title_placeholders": {"name": self._drop_discovery.name}}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._drop_discovery is not None + if user_input is not None: + device_data = { + CONF_COMMAND_TOPIC: self._drop_discovery.command_topic, + CONF_DATA_TOPIC: self._drop_discovery.data_topic, + CONF_DEVICE_DESC: self._drop_discovery.device_desc, + CONF_DEVICE_ID: self._drop_discovery.device_id, + CONF_DEVICE_NAME: self._drop_discovery.name, + CONF_DEVICE_TYPE: self._drop_discovery.device_type, + CONF_HUB_ID: self._drop_discovery.hub_id, + CONF_DEVICE_OWNER_ID: self._drop_discovery.owner_id, + } + return self.async_create_entry( + title=self._drop_discovery.name, data=device_data + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "device_name": self._drop_discovery.name, + "device_type": self._drop_discovery.device_desc, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") diff --git a/homeassistant/components/drop_connect/const.py b/homeassistant/components/drop_connect/const.py new file mode 100644 index 00000000000..38a8a57ea72 --- /dev/null +++ b/homeassistant/components/drop_connect/const.py @@ -0,0 +1,25 @@ +"""Constants for the drop_connect integration.""" + +# Keys for values used in the config_entry data dictionary +CONF_COMMAND_TOPIC = "drop_command_topic" +CONF_DATA_TOPIC = "drop_data_topic" +CONF_DEVICE_DESC = "device_desc" +CONF_DEVICE_ID = "device_id" +CONF_DEVICE_TYPE = "device_type" +CONF_HUB_ID = "drop_hub_id" +CONF_DEVICE_NAME = "name" +CONF_DEVICE_OWNER_ID = "drop_device_owner_id" + +# Values for DROP device types +DEV_FILTER = "filt" +DEV_HUB = "hub" +DEV_LEAK_DETECTOR = "leak" +DEV_PROTECTION_VALVE = "pv" +DEV_PUMP_CONTROLLER = "pc" +DEV_RO_FILTER = "ro" +DEV_SALT_SENSOR = "salt" +DEV_SOFTENER = "soft" + +DISCOVERY_TOPIC = "drop_connect/discovery/#" + +DOMAIN = "drop_connect" diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py new file mode 100644 index 00000000000..eb440d224d7 --- /dev/null +++ b/homeassistant/components/drop_connect/coordinator.py @@ -0,0 +1,25 @@ +"""DROP device data update coordinator object.""" +from __future__ import annotations + +import logging + +from dropmqttapi.mqttapi import DropAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): + """DROP device object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + """Initialize the device.""" + super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + self.drop_api = DropAPI() diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py new file mode 100644 index 00000000000..85c506b19a3 --- /dev/null +++ b/homeassistant/components/drop_connect/entity.py @@ -0,0 +1,53 @@ +"""Base entity class for DROP entities.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_DEVICE_DESC, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DEV_HUB, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator + + +class DROPEntity(CoordinatorEntity[DROPDeviceDataUpdateCoordinator]): + """Representation of a DROP device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, entity_type: str, coordinator: DROPDeviceDataUpdateCoordinator + ) -> None: + """Init DROP entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + unique_id = coordinator.config_entry.unique_id + self._attr_unique_id = f"{unique_id}_{entity_type}" + entry_data = coordinator.config_entry.data + model: str = entry_data[CONF_DEVICE_DESC] + if entry_data[CONF_DEVICE_TYPE] == DEV_HUB: + model = f"Hub {entry_data[CONF_HUB_ID]}" + self._attr_device_info = DeviceInfo( + manufacturer="Chandler Systems, Inc.", + model=model, + name=entry_data[CONF_DEVICE_NAME], + identifiers={(DOMAIN, unique_id)}, + ) + if entry_data[CONF_DEVICE_TYPE] != DEV_HUB: + self._attr_device_info.update( + { + "via_device": ( + DOMAIN, + entry_data[CONF_DEVICE_OWNER_ID], + ) + } + ) diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json new file mode 100644 index 00000000000..f65c1848aff --- /dev/null +++ b/homeassistant/components/drop_connect/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "drop_connect", + "name": "DROP", + "codeowners": ["@ChandlerSystems", "@pfrazer"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/drop_connect", + "iot_class": "local_push", + "mqtt": ["drop_connect/discovery/#"], + "requirements": ["dropmqttapi==1.0.1"] +} diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py new file mode 100644 index 00000000000..c5215df8395 --- /dev/null +++ b/homeassistant/components/drop_connect/sensor.py @@ -0,0 +1,285 @@ +"""Support for DROP sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +FLOW_ICON = "mdi:shower-head" +GAUGE_ICON = "mdi:gauge" +TDS_ICON = "mdi:water-opacity" + +# Sensor type constants +CURRENT_FLOW_RATE = "current_flow_rate" +PEAK_FLOW_RATE = "peak_flow_rate" +WATER_USED_TODAY = "water_used_today" +AVERAGE_WATER_USED = "average_water_used" +CAPACITY_REMAINING = "capacity_remaining" +CURRENT_SYSTEM_PRESSURE = "current_system_pressure" +HIGH_SYSTEM_PRESSURE = "high_system_pressure" +LOW_SYSTEM_PRESSURE = "low_system_pressure" +BATTERY = "battery" +TEMPERATURE = "temperature" +INLET_TDS = "inlet_tds" +OUTLET_TDS = "outlet_tds" +CARTRIDGE_1_LIFE = "cart1" +CARTRIDGE_2_LIFE = "cart2" +CARTRIDGE_3_LIFE = "cart3" + + +@dataclass(kw_only=True, frozen=True) +class DROPSensorEntityDescription(SensorEntityDescription): + """Describes DROP sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], float | int | None] + + +SENSORS: list[DROPSensorEntityDescription] = [ + DROPSensorEntityDescription( + key=CURRENT_FLOW_RATE, + translation_key=CURRENT_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=PEAK_FLOW_RATE, + translation_key=PEAK_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.peak_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=WATER_USED_TODAY, + translation_key=WATER_USED_TODAY, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.water_used_today(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=AVERAGE_WATER_USED, + translation_key=AVERAGE_WATER_USED, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.average_water_used(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CAPACITY_REMAINING, + translation_key=CAPACITY_REMAINING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.capacity_remaining(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CURRENT_SYSTEM_PRESSURE, + translation_key=CURRENT_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=HIGH_SYSTEM_PRESSURE, + translation_key=HIGH_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.high_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=LOW_SYSTEM_PRESSURE, + translation_key=LOW_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.low_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=BATTERY, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.battery(), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DROPSensorEntityDescription( + key=TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.temperature(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=INLET_TDS, + translation_key=INLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.inlet_tds(), + ), + DROPSensorEntityDescription( + key=OUTLET_TDS, + translation_key=OUTLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.outlet_tds(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_1_LIFE, + translation_key=CARTRIDGE_1_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart1(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_2_LIFE, + translation_key=CARTRIDGE_2_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart2(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_3_LIFE, + translation_key=CARTRIDGE_3_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart3(), + ), +] + +# Defines which sensors are used by each device type +DEVICE_SENSORS: dict[str, list[str]] = { + DEV_HUB: [ + AVERAGE_WATER_USED, + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + HIGH_SYSTEM_PRESSURE, + LOW_SYSTEM_PRESSURE, + PEAK_FLOW_RATE, + WATER_USED_TODAY, + ], + DEV_SOFTENER: [ + BATTERY, + CAPACITY_REMAINING, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + ], + DEV_FILTER: [BATTERY, CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE], + DEV_LEAK_DETECTOR: [BATTERY, TEMPERATURE], + DEV_PROTECTION_VALVE: [ + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + TEMPERATURE, + ], + DEV_PUMP_CONTROLLER: [CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE, TEMPERATURE], + DEV_RO_FILTER: [ + CARTRIDGE_1_LIFE, + CARTRIDGE_2_LIFE, + CARTRIDGE_3_LIFE, + INLET_TDS, + OUTLET_TDS, + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP sensors from config entry.""" + _LOGGER.debug( + "Set up sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: + async_add_entities( + DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in SENSORS + if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSensor(DROPEntity, SensorEntity): + """Representation of a DROP sensor.""" + + entity_description: DROPSensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def native_value(self) -> float | int | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json new file mode 100644 index 00000000000..0674515412f --- /dev/null +++ b/homeassistant/components/drop_connect/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "not_supported": "Configuration for DROP is through MQTT discovery. Use the DROP Connect app to connect your DROP Hub to your MQTT broker." + }, + "step": { + "confirm": { + "title": "Confirm association", + "description": "Do you want to configure the DROP {device_type} named {device_name}?'" + } + } + }, + "entity": { + "sensor": { + "current_flow_rate": { "name": "Water flow rate" }, + "peak_flow_rate": { "name": "Peak water flow rate today" }, + "water_used_today": { "name": "Total water used today" }, + "average_water_used": { "name": "Average daily water usage" }, + "capacity_remaining": { "name": "Capacity remaining" }, + "current_system_pressure": { "name": "Current water pressure" }, + "high_system_pressure": { "name": "High water pressure today" }, + "low_system_pressure": { "name": "Low water pressure today" }, + "inlet_tds": { "name": "Inlet TDS" }, + "outlet_tds": { "name": "Outlet TDS" }, + "cart1": { "name": "Cartridge 1 life remaining" }, + "cart2": { "name": "Cartridge 2 life remaining" }, + "cart3": { "name": "Cartridge 3 life remaining" } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1deeae819a0..9fcc3ea93b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -111,6 +111,7 @@ FLOWS = { "doorbird", "dormakaba_dkey", "dremel_3d_printer", + "drop_connect", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9479153dd0d..61cb665af2f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1253,6 +1253,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "drop_connect": { + "name": "DROP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 69abf7c64fe..0c456774e4d 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -4,6 +4,9 @@ To update, run python3 -m script.hassfest """ MQTT = { + "drop_connect": [ + "drop_connect/discovery/#", + ], "dsmr_reader": [ "dsmr/#", ], diff --git a/requirements_all.txt b/requirements_all.txt index b903bc7daca..ce2d1672483 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -712,6 +712,9 @@ dovado==0.4.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88b9fa3e8d0..274a928e8b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -581,6 +581,9 @@ discovery30303==0.2.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 diff --git a/tests/components/drop_connect/__init__.py b/tests/components/drop_connect/__init__.py new file mode 100644 index 00000000000..f67b77b906e --- /dev/null +++ b/tests/components/drop_connect/__init__.py @@ -0,0 +1 @@ +"""Tests for the DROP integration.""" diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py new file mode 100644 index 00000000000..9a07c71cb71 --- /dev/null +++ b/tests/components/drop_connect/common.py @@ -0,0 +1,51 @@ +"""Define common test values.""" + +TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" +TEST_DATA_HUB = ( + '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' + '"water":1,"bypass":0,"pMode":"HOME","battery":50,"notif":1,"leak":0}' +) +TEST_DATA_HUB_RESET = ( + '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' + '"water":0,"bypass":0,"pMode":"AWAY","battery":0,"notif":0,"leak":0}' +) + +TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" +TEST_DATA_SALT = '{"salt":1}' +TEST_DATA_SALT_RESET = '{"salt":0}' + +TEST_DATA_LEAK_TOPIC = "drop_connect/DROP-1_C0FFEE/20" +TEST_DATA_LEAK = '{"battery":100,"leak":1,"temp":68.2}' +TEST_DATA_LEAK_RESET = '{"battery":0,"leak":0,"temp":0}' + +TEST_DATA_SOFTENER_TOPIC = "drop_connect/DROP-1_C0FFEE/0" +TEST_DATA_SOFTENER = ( + '{"curFlow":5.0,"bypass":0,"battery":20,"capacity":1000,"resInUse":1,"psi":50.5}' +) +TEST_DATA_SOFTENER_RESET = ( + '{"curFlow":0,"bypass":0,"battery":0,"capacity":0,"resInUse":0,"psi":null}' +) + +TEST_DATA_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/4" +TEST_DATA_FILTER = '{"curFlow":19.84,"bypass":0,"battery":12,"psi":38.2}' +TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":0,"battery":0,"psi":null}' + +TEST_DATA_PROTECTION_VALVE_TOPIC = "drop_connect/DROP-1_C0FFEE/78" +TEST_DATA_PROTECTION_VALVE = ( + '{"curFlow":7.1,"psi":61.3,"water":1,"battery":0,"leak":1,"temp":70.5}' +) +TEST_DATA_PROTECTION_VALVE_RESET = ( + '{"curFlow":0,"psi":0,"water":0,"battery":0,"leak":0,"temp":0}' +) + +TEST_DATA_PUMP_CONTROLLER_TOPIC = "drop_connect/DROP-1_C0FFEE/83" +TEST_DATA_PUMP_CONTROLLER = '{"curFlow":2.2,"psi":62.2,"pump":1,"leak":1,"temp":68.8}' +TEST_DATA_PUMP_CONTROLLER_RESET = '{"curFlow":0,"psi":0,"pump":0,"leak":0,"temp":0}' + +TEST_DATA_RO_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/95" +TEST_DATA_RO_FILTER = ( + '{"leak":1,"tdsIn":164,"tdsOut":9,"cart1":59,"cart2":80,"cart3":59}' +) +TEST_DATA_RO_FILTER_RESET = ( + '{"leak":0,"tdsIn":0,"tdsOut":0,"cart1":0,"cart2":0,"cart3":0}' +) diff --git a/tests/components/drop_connect/conftest.py b/tests/components/drop_connect/conftest.py new file mode 100644 index 00000000000..ce68a6f0c13 --- /dev/null +++ b/tests/components/drop_connect/conftest.py @@ -0,0 +1,177 @@ +"""Define fixtures available for all tests.""" +import pytest + +from homeassistant.components.drop_connect.const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry_hub(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", + CONF_DEVICE_DESC: "Hub", + CONF_DEVICE_ID: 255, + CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", + CONF_DEVICE_TYPE: "hub", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_salt(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_8", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", + CONF_DEVICE_DESC: "Salt Sensor", + CONF_DEVICE_ID: 8, + CONF_DEVICE_NAME: "Salt Sensor", + CONF_DEVICE_TYPE: "salt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_leak(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_20", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", + CONF_DEVICE_DESC: "Leak Detector", + CONF_DEVICE_ID: 20, + CONF_DEVICE_NAME: "Leak Detector", + CONF_DEVICE_TYPE: "leak", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_softener(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_0", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", + CONF_DEVICE_DESC: "Softener", + CONF_DEVICE_ID: 0, + CONF_DEVICE_NAME: "Softener", + CONF_DEVICE_TYPE: "soft", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_4", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", + CONF_DEVICE_DESC: "Filter", + CONF_DEVICE_ID: 4, + CONF_DEVICE_NAME: "Filter", + CONF_DEVICE_TYPE: "filt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_protection_valve(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_78", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", + CONF_DEVICE_DESC: "Protection Valve", + CONF_DEVICE_ID: 78, + CONF_DEVICE_NAME: "Protection Valve", + CONF_DEVICE_TYPE: "pv", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_pump_controller(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_83", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", + CONF_DEVICE_DESC: "Pump Controller", + CONF_DEVICE_ID: 83, + CONF_DEVICE_NAME: "Pump Controller", + CONF_DEVICE_TYPE: "pc", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_ro_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", + CONF_DEVICE_DESC: "RO Filter", + CONF_DEVICE_ID: 95, + CONF_DEVICE_NAME: "RO Filter", + CONF_DEVICE_TYPE: "ro", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) diff --git a/tests/components/drop_connect/test_config_flow.py b/tests/components/drop_connect/test_config_flow.py new file mode 100644 index 00000000000..fb727d2c7fd --- /dev/null +++ b/tests/components/drop_connect/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test config flow.""" +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.typing import MqttMockHAClient + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "drop_command_topic": "drop_connect/DROP-1_C0FFEE/cmd/255", + "drop_data_topic": "drop_connect/DROP-1_C0FFEE/data/255/#", + "device_desc": "Hub", + "device_id": "255", + "name": "Hub DROP-1_C0FFEE", + "device_type": "hub", + "drop_hub_id": "DROP-1_C0FFEE", + "drop_device_owner_id": "DROP-1_C0FFEE_255", + } + + +async def test_duplicate(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Attempting configuration of the same object should abort + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_incomplete_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_json( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="{BAD JSON}", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/FOO", + payload=('{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}'), + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_no_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test user setup.""" + result = await hass.config_entries.flow.async_init( + "drop_connect", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "not_supported" diff --git a/tests/components/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_coordinator.py new file mode 100644 index 00000000000..50f2633e241 --- /dev/null +++ b/tests/components/drop_connect/test_coordinator.py @@ -0,0 +1,74 @@ +"""Test DROP coordinator.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_bad_json( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test bad JSON.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNKNOWN + + +async def test_unload( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test entity unload.""" + # Load the hub device + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + # Unload the device + await hass.config_entries.async_unload(config_entry_hub.entry_id) + await hass.async_block_till_done() + + assert config_entry_hub.state is ConfigEntryState.NOT_LOADED + + # Verify sensor is unavailable + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNAVAILABLE + + +async def test_no_mqtt(hass: HomeAssistant, config_entry_hub) -> None: + """Test no MQTT.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select is None diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py new file mode 100644 index 00000000000..589fd08488c --- /dev/null +++ b/tests/components/drop_connect/test_sensor.py @@ -0,0 +1,319 @@ +"""Test DROP sensor entities.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_sensors_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + peak_flow_sensor_name = "sensor.hub_drop_1_c0ffee_peak_water_flow_rate_today" + hass.states.async_set(peak_flow_sensor_name, STATE_UNKNOWN) + used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" + hass.states.async_set(used_today_sensor_name, STATE_UNKNOWN) + average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" + hass.states.async_set(average_usage_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" + hass.states.async_set(psi_high_sensor_name, STATE_UNKNOWN) + psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" + hass.states.async_set(psi_low_sensor_name, STATE_UNKNOWN) + battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + peak_flow_sensor = hass.states.get(peak_flow_sensor_name) + assert peak_flow_sensor + assert round(float(peak_flow_sensor.state), 1) == 13.8 + + used_today_sensor = hass.states.get(used_today_sensor_name) + assert used_today_sensor + assert round(float(used_today_sensor.state), 1) == 881.1 # liters + + average_usage_sensor = hass.states.get(average_usage_sensor_name) + assert average_usage_sensor + assert round(float(average_usage_sensor.state), 1) == 287.7 # liters + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + psi_high_sensor = hass.states.get(psi_high_sensor_name) + assert psi_high_sensor + assert round(float(psi_high_sensor.state), 1) == 427.5 # centibars + + psi_low_sensor = hass.states.get(psi_low_sensor_name) + assert psi_low_sensor + assert round(float(psi_low_sensor.state), 1) == 420.6 # centibars + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 50 + + +async def test_sensors_leak( + hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for leak detectors.""" + config_entry_leak.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.leak_detector_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.leak_detector_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 100 + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.1 # C + + +async def test_sensors_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.softener_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.softener_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.softener_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + capacity_sensor_name = "sensor.softener_capacity_remaining" + hass.states.async_set(capacity_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 20 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.0 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 348.2 # centibars + + capacity_sensor = hass.states.get(capacity_sensor_name) + assert capacity_sensor + assert round(float(capacity_sensor.state), 1) == 3785.4 # liters + + +async def test_sensors_filter( + hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for filters.""" + config_entry_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.filter_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.filter_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.filter_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert round(float(battery_sensor.state), 1) == 12.0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 19.8 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 263.4 # centibars + + +async def test_sensors_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.protection_valve_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.protection_valve_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.protection_valve_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 7.1 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 422.6 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 21.4 # C + + +async def test_sensors_pump_controller( + hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for pump controllers.""" + config_entry_pump_controller.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.pump_controller_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.pump_controller_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.pump_controller_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 2.2 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.4 # C + + +async def test_sensors_ro_filter( + hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for RO filters.""" + config_entry_ro_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + tds_in_sensor_name = "sensor.ro_filter_inlet_tds" + hass.states.async_set(tds_in_sensor_name, STATE_UNKNOWN) + tds_out_sensor_name = "sensor.ro_filter_outlet_tds" + hass.states.async_set(tds_out_sensor_name, STATE_UNKNOWN) + cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" + hass.states.async_set(cart1_sensor_name, STATE_UNKNOWN) + cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" + hass.states.async_set(cart2_sensor_name, STATE_UNKNOWN) + cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" + hass.states.async_set(cart3_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + tds_in_sensor = hass.states.get(tds_in_sensor_name) + assert tds_in_sensor + assert int(tds_in_sensor.state) == 164 + + tds_out_sensor = hass.states.get(tds_out_sensor_name) + assert tds_out_sensor + assert int(tds_out_sensor.state) == 9 + + cart1_sensor = hass.states.get(cart1_sensor_name) + assert cart1_sensor + assert int(cart1_sensor.state) == 59 + + cart2_sensor = hass.states.get(cart2_sensor_name) + assert cart2_sensor + assert int(cart2_sensor.state) == 80 + + cart3_sensor = hass.states.get(cart3_sensor_name) + assert cart3_sensor + assert int(cart3_sensor.state) == 59 From 922cef2884e16fd27756cbca491e8afce761a62d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 22 Dec 2023 14:27:28 +0100 Subject: [PATCH 622/927] Don't make huawei_lte entity classes dataclasses (#106160) --- .../components/huawei_lte/__init__.py | 13 ++--- .../components/huawei_lte/binary_sensor.py | 53 +++++++------------ .../components/huawei_lte/device_tracker.py | 15 +++--- homeassistant/components/huawei_lte/notify.py | 8 +-- homeassistant/components/huawei_lte/select.py | 23 +++++--- homeassistant/components/huawei_lte/sensor.py | 25 ++++++--- homeassistant/components/huawei_lte/switch.py | 30 ++++------- 7 files changed, 79 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index dcd40b8346c..42a1e066ac7 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -616,17 +616,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -@dataclass class HuaweiLteBaseEntity(Entity): """Huawei LTE entity base class.""" - router: Router - - _available: bool = field(default=True, init=False) - _unsub_handlers: list[Callable] = field(default_factory=list, init=False) - _attr_has_entity_name: bool = field(default=True, init=False) + _available = True + _attr_has_entity_name = True _attr_should_poll = False + def __init__(self, router: Router) -> None: + """Initialize.""" + self.router = router + self._unsub_handlers: list[Callable] = [] + @property def _device_unique_id(self) -> str: """Return unique ID for entity within a router.""" diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index bf63422ae3a..7f709b02dc2 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -1,7 +1,6 @@ """Support for Huawei LTE binary sensors.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -48,15 +47,14 @@ async def async_setup_entry( async_add_entities(entities, True) -@dataclass class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" _attr_entity_registry_enabled_default = False - key: str = field(init=False) - item: str = field(init=False) - _raw_state: str | None = field(default=None, init=False) + key: str + item: str + _raw_state: str | None = None @property def _device_unique_id(self) -> str: @@ -100,17 +98,14 @@ CONNECTION_STATE_ATTRIBUTES = { } -@dataclass class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" - _attr_translation_key: str = field(default="mobile_connection", init=False) + _attr_translation_key = "mobile_connection" _attr_entity_registry_enabled_default = True - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "ConnectionStatus" + key = KEY_MONITORING_STATUS + item = "ConnectionStatus" @property def is_on(self) -> bool: @@ -165,52 +160,40 @@ class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): return "mdi:wifi" if self.is_on else "mdi:wifi-off" -@dataclass class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" - _attr_translation_key: str = field(default="wifi_status", init=False) + _attr_translation_key: str = "wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "WifiStatus" + key = KEY_MONITORING_STATUS + item = "WifiStatus" -@dataclass class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 2.4GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="24ghz_wifi_status", init=False) + _attr_translation_key: str = "24ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi24g_switch_enable" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi24g_switch_enable" -@dataclass class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 5GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="5ghz_wifi_status", init=False) + _attr_translation_key: str = "5ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi5g_enabled" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi5g_enabled" -@dataclass class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE SMS storage full binary sensor.""" - _attr_translation_key: str = field(default="sms_storage_full", init=False) + _attr_translation_key: str = "sms_storage_full" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_CHECK_NOTIFICATIONS - self.item = "SmsStorageFull" + key = KEY_MONITORING_CHECK_NOTIFICATIONS + item = "SmsStorageFull" @property def is_on(self) -> bool: diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 665c96e4888..fd1b9850054 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,7 +1,6 @@ """Support for device tracking of Huawei LTE routers.""" from __future__ import annotations -from dataclasses import dataclass, field import logging import re from typing import Any, cast @@ -173,16 +172,18 @@ def _better_snakecase(text: str) -> str: return cast(str, snakecase(text)) -@dataclass class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): """Huawei LTE router scanner entity.""" - _mac_address: str + _ip_address: str | None = None + _is_connected: bool = False + _hostname: str | None = None - _ip_address: str | None = field(default=None, init=False) - _is_connected: bool = field(default=False, init=False) - _hostname: str | None = field(default=None, init=False) - _extra_state_attributes: dict[str, Any] = field(default_factory=dict, init=False) + def __init__(self, router: Router, mac_address: str) -> None: + """Initialize.""" + super().__init__(router) + self._extra_state_attributes: dict[str, Any] = {} + self._mac_address = mac_address @property def name(self) -> str: diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 4474188ea22..3b72e2216a6 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,7 +1,6 @@ """Support for Huawei LTE router notifications.""" from __future__ import annotations -from dataclasses import dataclass import logging import time from typing import Any @@ -34,12 +33,13 @@ async def async_get_service( return HuaweiLteSmsNotificationService(router, default_targets) -@dataclass class HuaweiLteSmsNotificationService(BaseNotificationService): """Huawei LTE router SMS notification service.""" - router: Router - default_targets: list[str] + def __init__(self, router: Router, default_targets: list[str]) -> None: + """Initialize.""" + self.router = router + self.default_targets = default_targets def send_message(self, message: str = "", **kwargs: Any) -> None: """Send message to target numbers.""" diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index 83b5d5545cb..f211da3c2e8 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import partial import logging @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED -from . import HuaweiLteBaseEntityWithDevice +from . import HuaweiLteBaseEntityWithDevice, Router from .const import DOMAIN, KEY_NET_NET_MODE _LOGGER = logging.getLogger(__name__) @@ -80,18 +80,25 @@ async def async_setup_entry( async_add_entities(selects, True) -@dataclass class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): """Huawei LTE select entity.""" entity_description: HuaweiSelectEntityDescription - key: str - item: str + _raw_state: str | None = None - _raw_state: str | None = field(default=None, init=False) + def __init__( + self, + router: Router, + entity_description: HuaweiSelectEntityDescription, + key: str, + item: str, + ) -> None: + """Initialize.""" + super().__init__(router) + self.entity_description = entity_description + self.key = key + self.item = item - def __post_init__(self) -> None: - """Initialize remaining attributes.""" name = None if self.entity_description.name != UNDEFINED: name = self.entity_description.name diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index d47305fa5f6..d7fb5565969 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from bisect import bisect from collections.abc import Callable, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timedelta import logging import re @@ -29,7 +29,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HuaweiLteBaseEntityWithDevice +from . import HuaweiLteBaseEntityWithDevice, Router from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, @@ -688,17 +688,26 @@ async def async_setup_entry( async_add_entities(sensors, True) -@dataclass class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): """Huawei LTE sensor entity.""" - key: str - item: str entity_description: HuaweiSensorEntityDescription + _state: StateType = None + _unit: str | None = None + _last_reset: datetime | None = None - _state: StateType = field(default=None, init=False) - _unit: str | None = field(default=None, init=False) - _last_reset: datetime | None = field(default=None, init=False) + def __init__( + self, + router: Router, + key: str, + item: str, + entity_description: HuaweiSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(router) + self.key = key + self.item = item + self.entity_description = entity_description async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index eb9370a946f..651099be42d 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -1,7 +1,6 @@ """Support for Huawei LTE switches.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -43,17 +42,14 @@ async def async_setup_entry( async_add_entities(switches, True) -@dataclass class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): """Huawei LTE switch device base class.""" - key: str = field(init=False) - item: str = field(init=False) + key: str + item: str - _attr_device_class: SwitchDeviceClass = field( - default=SwitchDeviceClass.SWITCH, init=False - ) - _raw_state: str | None = field(default=None, init=False) + _attr_device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH + _raw_state: str | None = None def _turn(self, state: bool) -> None: raise NotImplementedError @@ -88,16 +84,13 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): self._raw_state = str(value) -@dataclass class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): """Huawei LTE mobile data switch device.""" - _attr_translation_key: str = field(default="mobile_data", init=False) + _attr_translation_key: str = "mobile_data" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_DIALUP_MOBILE_DATASWITCH - self.item = "dataswitch" + key = KEY_DIALUP_MOBILE_DATASWITCH + item = "dataswitch" @property def _device_unique_id(self) -> str: @@ -120,16 +113,13 @@ class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): return "mdi:signal" if self.is_on else "mdi:signal-off" -@dataclass class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" - _attr_translation_key: str = field(default="wifi_guest_network", init=False) + _attr_translation_key: str = "wifi_guest_network" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH - self.item = "WifiEnable" + key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH + item = "WifiEnable" @property def _device_unique_id(self) -> str: From a579a0c80af33e7bbe3a63c7d0223a2da36c44c3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 22 Dec 2023 14:50:07 +0100 Subject: [PATCH 623/927] Update coverage to 7.3.4 (#106233) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 68395d9b867..3a552741812 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.3 +coverage==7.3.4 freezegun==1.3.1 mock-open==1.4.0 mypy==1.8.0 From 13504d5fd5a62a472b61ec0ff2aa8c9c8e2f0651 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Dec 2023 14:50:58 +0100 Subject: [PATCH 624/927] Add consider home interval to ping (#104881) * Add consider home interval to ping * Run ruff after rebase * Fix buggy consider home interval --- homeassistant/components/ping/config_flow.py | 19 +++++++++++- .../components/ping/device_tracker.py | 24 +++++++++++++-- homeassistant/components/ping/strings.json | 9 ++++-- tests/components/ping/conftest.py | 7 ++++- tests/components/ping/const.py | 3 ++ tests/components/ping/test_config_flow.py | 6 +++- tests/components/ping/test_device_tracker.py | 30 +++++++++++++++++-- 7 files changed, 88 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 42cdd3f3a77..29b8a8ba2a5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -8,6 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -45,7 +49,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input[CONF_HOST], data={}, - options={**user_input, CONF_PING_COUNT: DEFAULT_PING_COUNT}, + options={ + **user_input, + CONF_PING_COUNT: DEFAULT_PING_COUNT, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.seconds, + }, ) async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: @@ -54,6 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): to_import = { CONF_HOST: import_info[CONF_HOST], CONF_PING_COUNT: import_info[CONF_PING_COUNT], + CONF_CONSIDER_HOME: import_info.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ).seconds, } title = import_info.get(CONF_NAME, import_info[CONF_HOST]) @@ -102,6 +113,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): min=1, max=100, mode=selector.NumberSelectorMode.BOX ) ), + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ), + ): int, } ), ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 417659aad5c..d627082a499 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,12 +1,15 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations +from datetime import datetime, timedelta import logging from typing import Any import voluptuous as vol from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, ScannerEntity, @@ -31,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN @@ -91,6 +95,7 @@ async def async_setup_scanner( CONF_NAME: dev_name, CONF_HOST: dev_host, CONF_PING_COUNT: config[CONF_PING_COUNT], + CONF_CONSIDER_HOME: config[CONF_CONSIDER_HOME], }, ) ) @@ -131,6 +136,8 @@ async def async_setup_entry( class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + _first_offline: datetime | None = None + def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: @@ -139,6 +146,11 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) self._attr_name = config_entry.title self.config_entry = config_entry + self._consider_home_interval = timedelta( + seconds=config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ) + ) @property def ip_address(self) -> str: @@ -157,8 +169,16 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) @property def is_connected(self) -> bool: - """Return true if ping returns is_alive.""" - return self.coordinator.data.is_alive + """Return true if ping returns is_alive or considered home.""" + if self.coordinator.data.is_alive: + self._first_offline = None + return True + + now = dt_util.utcnow() + if self._first_offline is None: + self._first_offline = now + + return (self._first_offline + self._consider_home_interval) > now @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 12bc1d25c7a..421d9079c62 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -5,8 +5,7 @@ "title": "Add Ping", "description": "Ping allows you to check the availability of a host.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "count": "Ping count" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of the device you want to ping." @@ -23,7 +22,11 @@ "init": { "data": { "host": "[%key:common::config_flow::data::host%]", - "count": "[%key:component::ping::config::step::user::data::count%]" + "count": "Ping count", + "consider_home": "Consider home interval" + }, + "data_description": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." } } }, diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index 4ad06a09c1c..24dd3314e3c 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch from icmplib import Host import pytest +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME from homeassistant.components.ping import DOMAIN from homeassistant.components.ping.const import CONF_PING_COUNT from homeassistant.const import CONF_HOST @@ -39,7 +40,11 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title="10.10.10.10", - options={CONF_HOST: "10.10.10.10", CONF_PING_COUNT: 10.0}, + options={ + CONF_HOST: "10.10.10.10", + CONF_PING_COUNT: 10.0, + CONF_CONSIDER_HOME: 180, + }, ) diff --git a/tests/components/ping/const.py b/tests/components/ping/const.py index cf002dc7ca6..048924292c7 100644 --- a/tests/components/ping/const.py +++ b/tests/components/ping/const.py @@ -1,4 +1,6 @@ """Constants for tests.""" +from datetime import timedelta + from icmplib import Host BINARY_SENSOR_IMPORT_DATA = { @@ -6,6 +8,7 @@ BINARY_SENSOR_IMPORT_DATA = { "host": "127.0.0.1", "count": 1, "scan_interval": 50, + "consider_home": timedelta(seconds=240), } NON_AVAILABLE_HOST_PING = Host("192.168.178.1", 10, []) diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 6fff4ae7c71..8757a5b5e0d 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -42,6 +42,7 @@ async def test_form(hass: HomeAssistant, host, expected_title) -> None: assert result["options"] == { "count": 5, "host": host, + "consider_home": 180, } @@ -58,7 +59,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None source=config_entries.SOURCE_USER, data={}, domain=DOMAIN, - options={"count": count, "host": host}, + options={"count": count, "host": host, "consider_home": 180}, title=expected_title, ) config_entry.add_to_hass(hass) @@ -83,6 +84,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None assert result["data"] == { "count": count, "host": "10.10.10.1", + "consider_home": 180, } @@ -103,6 +105,7 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "127.0.0.1", "count": 1, + "consider_home": 240, } # test import without name @@ -119,4 +122,5 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "10.10.10.10", "count": 5, + "consider_home": 180, } diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index 5f5bb2132c1..d91cb46da0c 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,6 +1,9 @@ """Test the binary sensor platform of ping.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host import pytest from homeassistant.components.device_tracker import legacy @@ -11,7 +14,7 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry, patch_yaml_files +from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -19,6 +22,7 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -42,10 +46,32 @@ async def test_setup_and_update( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - # check device tracker is now "home" state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + # we need to travel two times into the future to run the update twice + freezer.tick(timedelta(minutes=1, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=4, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "not_home" + + freezer.tick(timedelta(minutes=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "home" + async def test_import_issue_creation( hass: HomeAssistant, From f06d956da71ab09b524ea29f64b3f520fc7507aa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 22 Dec 2023 14:52:31 +0100 Subject: [PATCH 625/927] Update pytest warnings filter (#106234) --- pyproject.toml | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1569e0a7cc2..2f06e566bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -435,13 +435,13 @@ filterwarnings = [ "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.6/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/michaeldavie/env_canada/blob/v0.6.0/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", # https://github.com/allenporter/ical/pull/215 - v5.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 + # https://github.com/bachya/regenmaschine/blob/2023.12.0/regenmaschine/client.py#L57 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", # -- Setuptools DeprecationWarnings @@ -451,10 +451,6 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\('google.*'\\)`:DeprecationWarning:google.rpc", # -- tracked upstream / open PRs - # https://github.com/caronc/apprise/issues/659 - v1.4.5 - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", - # https://github.com/kiorky/croniter/issues/49 - v1.4.1 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 @@ -464,9 +460,8 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", - # https://github.com/frenck/python-toonapi/pull/9 - v0.2.1 - 2021-09-23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:toonapi.models", - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 + # https://github.com/foxel/python_ndms2_client/pull/8 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed @@ -475,17 +470,15 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/ludeeus/aiogithubapi/pull/208 - >=23.9.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiogithubapi.namespaces.events", - # https://github.com/bachya/aiopurpleair/pull/200 - >2023.08.0 + # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://github.com/scrapinghub/dateparser/pull/1179 - >1.1.8 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", - # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", + # https://github.com/kiorky/croniter/pull/52 - >=2.0.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/bachya/pytile/pull/280 - >2023.08.0 + # https://github.com/bachya/pytile/pull/280 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", @@ -494,12 +487,12 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # Fixed upstream in python-telegram-bot - >=20.0 "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", - # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:xiaomi_ble.parser", + # https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:zeep.utils", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -507,10 +500,8 @@ filterwarnings = [ # -- other # Locale changes might take some time to resolve upstream - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", - # https://github.com/protocolbuffers/protobuf - v4.24.4 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:google.protobuf.internal.well_known_types", + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://github.com/protocolbuffers/protobuf - v4.25.1 "ignore:Type google._upb._message.(Message|Scalar)MapContainer uses PyType_Spec with a metaclass that has custom tp_new. .* Python 3.14:DeprecationWarning", # https://github.com/googleapis/google-auth-library-python/blob/v2.23.3/google/auth/_helpers.py#L95 - v2.23.3 "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google.auth._helpers", @@ -518,6 +509,12 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # Fixed for Python 3.12 + # https://github.com/lextudio/pysnmp/issues/10 + "ignore:The asyncore module is deprecated and will be removed in Python 3.12:DeprecationWarning:pysnmp.carrier.asyncore.base", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", From 126a58a33ef49fac6b73fdd6d2271ee889d83372 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 22 Dec 2023 15:18:16 +0100 Subject: [PATCH 626/927] Return multiple trains in Trafikverket Train (#106206) * Add list of trains to coordinator * Fix to work * snapshot * Fixes * Fix --- .coveragerc | 1 - .../trafikverket_train/coordinator.py | 31 ++- .../components/trafikverket_train/sensor.py | 14 ++ .../trafikverket_train/strings.json | 16 ++ .../components/trafikverket_train/__init__.py | 27 +++ .../components/trafikverket_train/conftest.py | 160 +++++++++++++ .../snapshots/test_sensor.ambr | 216 ++++++++++++++++++ .../trafikverket_train/test_config_flow.py | 2 +- .../trafikverket_train/test_sensor.py | 74 ++++++ 9 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 tests/components/trafikverket_train/conftest.py create mode 100644 tests/components/trafikverket_train/snapshots/test_sensor.ambr create mode 100644 tests/components/trafikverket_train/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 528ec2e3dac..32622accd9a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1395,7 +1395,6 @@ omit = homeassistant/components/tradfri/switch.py homeassistant/components/trafikverket_train/__init__.py homeassistant/components/trafikverket_train/coordinator.py - homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_train/util.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index 91a7e9f07b2..d5402e44ec6 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -39,6 +39,8 @@ class TrainData: other_info: str | None deviation: str | None product_filter: str | None + departure_time_next: datetime | None + departure_time_next_next: datetime | None _LOGGER = logging.getLogger(__name__) @@ -91,6 +93,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): when = dt_util.now() state: TrainStop | None = None + states: list[TrainStop] | None = None if self._time: departure_day = next_departuredate(self._weekdays) when = datetime.combine( @@ -104,8 +107,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): self.from_station, self.to_station, when, self._filter_product ) else: - state = await self._train_api.async_get_next_train_stop( - self.from_station, self.to_station, when, self._filter_product + states = await self._train_api.async_get_next_train_stops( + self.from_station, + self.to_station, + when, + self._filter_product, + number_of_stops=3, ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error @@ -117,6 +124,20 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): f"Train departure {when} encountered a problem: {error}" ) from error + depart_next = None + depart_next_next = None + if not state and states: + state = states[0] + depart_next = ( + states[1].advertised_time_at_location if len(states) > 1 else None + ) + depart_next_next = ( + states[2].advertised_time_at_location if len(states) > 2 else None + ) + + if not state: + raise UpdateFailed("Could not find any departures") + departure_time = state.advertised_time_at_location if state.estimated_time_at_location: departure_time = state.estimated_time_at_location @@ -125,7 +146,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): delay_time = state.get_delay_time() - states = TrainData( + return TrainData( departure_time=_get_as_utc(departure_time), departure_state=state.get_state().value, cancelled=state.canceled, @@ -136,6 +157,6 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): other_info=_get_as_joined(state.other_information), deviation=_get_as_joined(state.deviations), product_filter=self._filter_product, + departure_time_next=_get_as_utc(depart_next), + departure_time_next_next=_get_as_utc(depart_next_next), ) - - return states diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 3aff376ab27..68865a64cb5 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -105,6 +105,20 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:alert", value_fn=lambda data: data.deviation, ), + TrafikverketSensorEntityDescription( + key="departure_time_next", + translation_key="departure_time_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next, + ), + TrafikverketSensorEntityDescription( + key="departure_time_next_next", + translation_key="departure_time_next_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next_next, + ), ) diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index a2c286867b2..89542211a92 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -69,6 +69,22 @@ } } }, + "departure_time_next": { + "name": "Departure time next", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "departure_time_next_next": { + "name": "Departure time next after", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, "departure_state": { "name": "Departure state", "state": { diff --git a/tests/components/trafikverket_train/__init__.py b/tests/components/trafikverket_train/__init__.py index 060b6a344a1..9a02ebbf3b6 100644 --- a/tests/components/trafikverket_train/__init__.py +++ b/tests/components/trafikverket_train/__init__.py @@ -1 +1,28 @@ """Tests for the Trafikverket Train integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_ferry.const import ( + CONF_FROM, + CONF_TIME, + CONF_TO, +) +from homeassistant.components.trafikverket_train.const import CONF_FILTER_PRODUCT +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: None, + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +ENTRY_CONFIG2 = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "11:00:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +OPTIONS_CONFIG = {CONF_FILTER_PRODUCT: "Regionaltåg"} diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py new file mode 100644 index 00000000000..dd9721a694e --- /dev/null +++ b/tests/components/trafikverket_train/conftest.py @@ -0,0 +1,160 @@ +"""Fixtures for Trafikverket Train integration tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_train import TrainStop + +from homeassistant.components.trafikverket_train.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, ENTRY_CONFIG2, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> MockConfigEntry: + """Set up the Trafikverket Train integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry.add_to_hass(hass) + config_entry2 = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG2, + entry_id="2", + unique_id="stockholmc-uppsalac-1100-['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry2.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ), patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_trains") +def fixture_get_trains() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_trains_next") +def fixture_get_trains_next() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_train_stop") +def fixture_get_train_stop() -> TrainStop: + """Construct TrainStop Mock.""" + + return TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) diff --git a/tests/components/trafikverket_train/snapshots/test_sensor.ambr b/tests/components/trafikverket_train/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..1fd0ba8552f --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_sensor.ambr @@ -0,0 +1,216 @@ +# serializer version: 1 +# name: test_sensor_next + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:15:00+00:00', + }) +# --- +# name: test_sensor_next.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:30:00+00:00', + }) +# --- +# name: test_sensor_next.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'Some other info', + }) +# --- +# name: test_sensor_next.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:15:00+00:00', + }) +# --- +# name: test_sensor_next.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:30:00+00:00', + }) +# --- +# name: test_sensor_next.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_single_stop + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_2', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T11:00:00+00:00', + }) +# --- diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 1accd4b5a55..c31d05bd038 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -196,7 +196,7 @@ async def test_flow_fails_departures( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", side_effect=side_effect(), ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", diff --git a/tests/components/trafikverket_train/test_sensor.py b/tests/components/trafikverket_train/test_sensor.py new file mode 100644 index 00000000000..8378dc0179e --- /dev/null +++ b/tests/components/trafikverket_train/test_sensor.py @@ -0,0 +1,74 @@ +"""The test for the Trafikverket train sensor platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_time_changed + + +async def test_sensor_next( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + get_train_stop: TrainStop, + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains_next, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + +async def test_sensor_single_stop( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + + assert state.state == "2023-05-01T11:00:00+00:00" + + assert state == snapshot From b7661b4d79ca77cf6698607350f53f15f6f8edc5 Mon Sep 17 00:00:00 2001 From: Alan Murray Date: Sat, 23 Dec 2023 01:23:39 +1100 Subject: [PATCH 627/927] Bump aiopulse to 0.4.4 (#106239) * Bump Rolease Acmeda version Bump aiopulse version to 0.4.4 to fix issue for blinds that use status structure to indicate blinds that are fully open or closed. * Update manifest.json * update requirements --- homeassistant/components/acmeda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json index 94dcf3325ca..a8b3c7c829f 100644 --- a/homeassistant/components/acmeda/manifest.json +++ b/homeassistant/components/acmeda/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/acmeda", "iot_class": "local_push", "loggers": ["aiopulse"], - "requirements": ["aiopulse==0.4.3"] + "requirements": ["aiopulse==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce2d1672483..4c542511f67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -321,7 +321,7 @@ aioopenexchangerates==0.4.0 aiopegelonline==0.0.6 # homeassistant.components.acmeda -aiopulse==0.4.3 +aiopulse==0.4.4 # homeassistant.components.purpleair aiopurpleair==2022.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 274a928e8b4..c25939d2063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -294,7 +294,7 @@ aioopenexchangerates==0.4.0 aiopegelonline==0.0.6 # homeassistant.components.acmeda -aiopulse==0.4.3 +aiopulse==0.4.4 # homeassistant.components.purpleair aiopurpleair==2022.12.1 From c91ac22d3cefca6e42fdf1143fe1c9daa44cefc1 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 22 Dec 2023 16:24:50 +0200 Subject: [PATCH 628/927] Add location selector to Islamic prayer times (#105911) * Add location selector to config flow * Simplify entry data * fix abort string * Add migration with minor version * Follow documented migration method --- .../islamic_prayer_times/__init__.py | 34 +++++++++- .../islamic_prayer_times/config_flow.py | 65 +++++++++++++++++-- .../islamic_prayer_times/coordinator.py | 9 ++- .../islamic_prayer_times/strings.json | 2 +- .../islamic_prayer_times/__init__.py | 8 +++ .../islamic_prayer_times/test_config_flow.py | 65 ++++++++++++++++--- .../islamic_prayer_times/test_init.py | 24 ++++++- 7 files changed, 185 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 2925ca527bc..86ef3ce271f 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,8 +1,10 @@ """The islamic_prayer_times component.""" from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -13,6 +15,8 @@ PLATFORMS = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" @@ -41,6 +45,34 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + lat = hass.config.latitude + lon = hass.config.longitude + new = { + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + } + unique_id = f"{lat}-{lon}" + config_entry.version = 1 + config_entry.minor_version = 2 + hass.config_entries.async_update_entry( + config_entry, data=new, unique_id=unique_id + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 333b6b36c87..2fde06f576d 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -3,16 +3,22 @@ from __future__ import annotations from typing import Any +from prayer_times_calculator import InvalidResponseError, PrayerTimesCalculator +from requests.exceptions import ConnectionError as ConnError import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, ) +import homeassistant.util.dt as dt_util from .const import ( CALC_METHODS, @@ -32,10 +38,31 @@ from .const import ( ) +async def async_validate_location( + hass: HomeAssistant, lon: float, lat: float +) -> dict[str, str]: + """Check if the selected location is valid.""" + errors = {} + calc = PrayerTimesCalculator( + latitude=lat, + longitude=lon, + calculation_method=DEFAULT_CALC_METHOD, + date=str(dt_util.now().date()), + ) + try: + await hass.async_add_executor_job(calc.fetch_prayer_times) + except InvalidResponseError: + errors["base"] = "invalid_location" + except ConnError: + errors["base"] = "conn_error" + return errors + + class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle the Islamic Prayer config flow.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -49,13 +76,39 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + errors = {} - if user_input is None: - return self.async_show_form(step_id="user") + if user_input is not None: + lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] + lon: float = user_input[CONF_LOCATION][CONF_LONGITUDE] + await self.async_set_unique_id(f"{lat}-{lon}") + self._abort_if_unique_id_configured() - return self.async_create_entry(title=NAME, data=user_input) + if not (errors := await async_validate_location(self.hass, lat, lon)): + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + }, + ) + + home_location = { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_NAME, default=NAME): TextSelector(), + vol.Required( + CONF_LOCATION, default=home_location + ): LocationSelector(), + } + ), + errors=errors, + ) class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index aedaf43411a..be138e7b45b 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -9,6 +9,7 @@ from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,12 +37,14 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def __init__(self, hass: HomeAssistant) -> None: """Initialize the Islamic Prayer client.""" - self.event_unsub: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, name=DOMAIN, ) + self.latitude = self.config_entry.data[CONF_LATITUDE] + self.longitude = self.config_entry.data[CONF_LONGITUDE] + self.event_unsub: CALLBACK_TYPE | None = None @property def calc_method(self) -> str: @@ -70,8 +73,8 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, + latitude=self.latitude, + longitude=self.longitude, calculation_method=self.calc_method, latitudeAdjustmentMethod=self.lat_adj_method, midnightMode=self.midnight_mode, diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index e07a38ca107..87703e5fdae 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -8,7 +8,7 @@ } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 8750461c47f..4df733a93fc 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -2,8 +2,16 @@ from datetime import datetime +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME import homeassistant.util.dt as dt_util +MOCK_USER_INPUT = { + CONF_NAME: "Home", + CONF_LOCATION: {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45}, +} + +MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} + PRAYER_TIMES = { "Fajr": "2020-01-01T06:10:00+00:00", "Sunrise": "2020-01-01T07:25:00+00:00", diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index f331c5bf49b..0375c788b11 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,5 +1,9 @@ """Tests for Islamic Prayer Times config flow.""" +from unittest.mock import patch + +from prayer_times_calculator import InvalidResponseError import pytest +from requests.exceptions import ConnectionError as ConnError from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times @@ -12,6 +16,8 @@ from homeassistant.components.islamic_prayer_times.const import ( ) from homeassistant.core import HomeAssistant +from . import MOCK_CONFIG, MOCK_USER_INPUT + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -25,13 +31,47 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.async_validate_location", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Islamic Prayer Times" + assert result["title"] == "Home" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidResponseError, "invalid_location"), + (ConnError, "conn_error"), + ], +) +async def test_flow_error( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test flow errors.""" + result = await hass.config_entries.flow.async_init( + islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.PrayerTimesCalculator.fetch_prayer_times", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == error async def test_options(hass: HomeAssistant) -> None: @@ -39,7 +79,7 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, title="Islamic Prayer Times", - data={}, + data=MOCK_CONFIG, options={CONF_CALC_METHOD: "isna"}, ) entry.add_to_hass(hass) @@ -68,14 +108,19 @@ async def test_options(hass: HomeAssistant) -> None: async def test_integration_already_configured(hass: HomeAssistant) -> None: """Test integration is already configured.""" entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={}, + domain=DOMAIN, data=MOCK_CONFIG, options={}, unique_id="12.34-23.45" ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 0c3f19e43fe..746abf27d43 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -10,7 +10,7 @@ from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -185,3 +185,25 @@ async def test_migrate_unique_id( entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" + + +async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: + """Test migrating from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=islamic_prayer_times.DOMAIN, + data={}, + ) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data == { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } + assert entry.minor_version == 2 From 989a7e7b106604b56ae1f8f04bf7f8415898d40f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 22 Dec 2023 15:59:01 +0100 Subject: [PATCH 629/927] Clean up swiss public transport (#106245) --- .../swiss_public_transport/sensor.py | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index e8c6e429d36..8365afebaa7 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -1,9 +1,12 @@ """Support for transport.opendata.ch.""" from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta import logging +from typing import Any +from opendata_transport import OpendataTransport from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol @@ -53,13 +56,11 @@ async def async_setup_entry( """Set up the sensor from a config entry created in the integrations UI.""" opendata = hass.data[DOMAIN][config_entry.entry_id] - start = config_entry.data[CONF_START] - destination = config_entry.data[CONF_DESTINATION] name = config_entry.title async_add_entities( - [SwissPublicTransportSensor(opendata, start, destination, name)], - update_before_add=True, + [SwissPublicTransportSensor(opendata, name)], + True, ) @@ -112,37 +113,28 @@ class SwissPublicTransportSensor(SensorEntity): _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" - def __init__(self, opendata, start, destination, name): + def __init__(self, opendata: OpendataTransport, name: str) -> None: """Initialize the sensor.""" self._opendata = opendata - self._name = name - self._from = start - self._to = destination - self._remaining_time = None + self._attr_name = name + self._remaining_time: timedelta | None = None @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> str: """Return the state of the sensor.""" - return ( - self._opendata.connections[0]["departure"] - if self._opendata is not None - else None - ) + return self._opendata.connections[0]["departure"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" - if self._opendata is None: - return - - self._remaining_time = dt_util.parse_datetime( + departure_time = dt_util.parse_datetime( self._opendata.connections[0]["departure"] - ) - dt_util.as_local(dt_util.utcnow()) + ) + if departure_time: + remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) + else: + remaining_time = None + self._remaining_time = remaining_time return { ATTR_TRAIN_NUMBER: self._opendata.connections[0]["number"], From 8b0d19aca287da86fe8ea535e4dc3959c3f4bad7 Mon Sep 17 00:00:00 2001 From: Floris272 <60342568+Floris272@users.noreply.github.com> Date: Fri, 22 Dec 2023 16:34:16 +0100 Subject: [PATCH 630/927] Add bluecurrent integration (#82483) * Add bluecurrent integration * Apply feedback * Rename integration * changed constants and removed strings.sensor.json * update blue_current integration * update bluecurrent-api to 1.0.4 * Update bluecurrent-api to 1.0.5 * Apply feedback * Remove translation * Apply feedback * Use customer_id as unique id * Apply feedback * Add @pytest.mark.parametrize * Replace loop.create_task with async_create_task --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/blue_current/__init__.py | 178 +++++++++++ .../components/blue_current/config_flow.py | 61 ++++ .../components/blue_current/const.py | 10 + .../components/blue_current/entity.py | 63 ++++ .../components/blue_current/manifest.json | 10 + .../components/blue_current/sensor.py | 296 ++++++++++++++++++ .../components/blue_current/strings.json | 117 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/blue_current/__init__.py | 52 +++ .../blue_current/test_config_flow.py | 89 ++++++ tests/components/blue_current/test_init.py | 185 +++++++++++ tests/components/blue_current/test_sensor.py | 181 +++++++++++ 18 files changed, 1268 insertions(+) create mode 100644 homeassistant/components/blue_current/__init__.py create mode 100644 homeassistant/components/blue_current/config_flow.py create mode 100644 homeassistant/components/blue_current/const.py create mode 100644 homeassistant/components/blue_current/entity.py create mode 100644 homeassistant/components/blue_current/manifest.json create mode 100644 homeassistant/components/blue_current/sensor.py create mode 100644 homeassistant/components/blue_current/strings.json create mode 100644 tests/components/blue_current/__init__.py create mode 100644 tests/components/blue_current/test_config_flow.py create mode 100644 tests/components/blue_current/test_init.py create mode 100644 tests/components/blue_current/test_sensor.py diff --git a/.strict-typing b/.strict-typing index d23da1c2fd2..a95981c9b65 100644 --- a/.strict-typing +++ b/.strict-typing @@ -81,6 +81,7 @@ homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* +homeassistant.components.blue_current.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* diff --git a/CODEOWNERS b/CODEOWNERS index d5ae7848b15..052d3b9258e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,8 @@ build.json @home-assistant/supervisor /tests/components/blebox/ @bbx-a @riokuu /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer +/homeassistant/components/blue_current/ @Floris272 @gleeuwen +/tests/components/blue_current/ @Floris272 @gleeuwen /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py new file mode 100644 index 00000000000..0dfa67f097d --- /dev/null +++ b/homeassistant/components/blue_current/__init__.py @@ -0,0 +1,178 @@ +"""The Blue Current integration.""" +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE + +PLATFORMS = [Platform.SENSOR] +CHARGE_POINTS = "CHARGE_POINTS" +DATA = "data" +SMALL_DELAY = 1 +LARGE_DELAY = 20 + +GRID = "GRID" +OBJECT = "object" +VALUE_TYPES = ["CH_STATUS"] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Blue Current as a config entry.""" + hass.data.setdefault(DOMAIN, {}) + client = Client() + api_token = config_entry.data[CONF_API_TOKEN] + connector = Connector(hass, config_entry, client) + + try: + await connector.connect(api_token) + except InvalidApiToken: + LOGGER.error("Invalid Api token") + return False + except BlueCurrentException as err: + raise ConfigEntryNotReady from err + + hass.async_create_task(connector.start_loop()) + await client.get_charge_points() + + await client.wait_for_response() + hass.data[DOMAIN][config_entry.entry_id] = connector + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload(connector.disconnect) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload the Blue Current config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class Connector: + """Define a class that connects to the Blue Current websocket API.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Initialize.""" + self.config: ConfigEntry = config + self.hass: HomeAssistant = hass + self.client: Client = client + self.charge_points: dict[str, dict] = {} + self.grid: dict[str, Any] = {} + self.available = False + + async def connect(self, token: str) -> None: + """Register on_data and connect to the websocket.""" + await self.client.connect(token) + self.available = True + + async def on_data(self, message: dict) -> None: + """Handle received data.""" + + async def handle_charge_points(data: list) -> None: + """Loop over the charge points and get their data.""" + for entry in data: + evse_id = entry[EVSE_ID] + model = entry[MODEL_TYPE] + name = entry[ATTR_NAME] + self.add_charge_point(evse_id, model, name) + await self.get_charge_point_data(evse_id) + await self.client.get_grid_status(data[0][EVSE_ID]) + + object_name: str = message[OBJECT] + + # gets charge point ids + if object_name == CHARGE_POINTS: + charge_points_data: list = message[DATA] + await handle_charge_points(charge_points_data) + + # gets charge point key / values + elif object_name in VALUE_TYPES: + value_data: dict = message[DATA] + evse_id = value_data.pop(EVSE_ID) + self.update_charge_point(evse_id, value_data) + + # gets grid key / values + elif GRID in object_name: + data: dict = message[DATA] + self.grid = data + self.dispatch_grid_update_signal() + + async def get_charge_point_data(self, evse_id: str) -> None: + """Get all the data of a charge point.""" + await self.client.get_status(evse_id) + + def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add a charge point to charge_points.""" + self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + + def update_charge_point(self, evse_id: str, data: dict) -> None: + """Update the charge point data.""" + self.charge_points[evse_id].update(data) + self.dispatch_value_update_signal(evse_id) + + def dispatch_value_update_signal(self, evse_id: str) -> None: + """Dispatch a value signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_value_update_{evse_id}") + + def dispatch_grid_update_signal(self) -> None: + """Dispatch a grid signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_grid_update") + + async def start_loop(self) -> None: + """Start the receive loop.""" + try: + await self.client.start_loop(self.on_data) + except BlueCurrentException as err: + LOGGER.warning( + "Disconnected from the Blue Current websocket. Retrying to connect in background. %s", + err, + ) + + async_call_later(self.hass, SMALL_DELAY, self.reconnect) + + async def reconnect(self, _event_time: datetime | None = None) -> None: + """Keep trying to reconnect to the websocket.""" + try: + await self.connect(self.config.data[CONF_API_TOKEN]) + LOGGER.info("Reconnected to the Blue Current websocket") + self.hass.async_create_task(self.start_loop()) + await self.client.get_charge_points() + except RequestLimitReached: + self.available = False + async_call_later( + self.hass, self.client.get_next_reset_delta(), self.reconnect + ) + except WebsocketError: + self.available = False + async_call_later(self.hass, LARGE_DELAY, self.reconnect) + + async def disconnect(self) -> None: + """Disconnect from the websocket.""" + with suppress(WebsocketError): + await self.client.disconnect() diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py new file mode 100644 index 00000000000..32a6c177b49 --- /dev/null +++ b/homeassistant/components/blue_current/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Blue Current integration.""" +from __future__ import annotations + +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the config flow for Blue Current.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + client = Client() + api_token = user_input[CONF_API_TOKEN] + + try: + customer_id = await client.validate_api_token(api_token) + email = await client.get_email() + except WebsocketError: + errors["base"] = "cannot_connect" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except InvalidApiToken: + errors["base"] = "invalid_token" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + await self.async_set_unique_id(customer_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py new file mode 100644 index 00000000000..008e6efa872 --- /dev/null +++ b/homeassistant/components/blue_current/const.py @@ -0,0 +1,10 @@ +"""Constants for the Blue Current integration.""" + +import logging + +DOMAIN = "blue_current" + +LOGGER = logging.getLogger(__package__) + +EVSE_ID = "evse_id" +MODEL_TYPE = "model_type" diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py new file mode 100644 index 00000000000..300f2191cdc --- /dev/null +++ b/homeassistant/components/blue_current/entity.py @@ -0,0 +1,63 @@ +"""Entity representing a Blue Current charge point.""" +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import Connector +from .const import DOMAIN, MODEL_TYPE + + +class BlueCurrentEntity(Entity): + """Define a base Blue Current entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, connector: Connector, signal: str) -> None: + """Initialize the entity.""" + self.connector: Connector = connector + self.signal: str = signal + self.has_value: bool = False + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(async_dispatcher_connect(self.hass, self.signal, update)) + + self.update_from_latest_data() + + @property + def available(self) -> bool: + """Return entity availability.""" + return self.connector.available and self.has_value + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + + +class ChargepointEntity(BlueCurrentEntity): + """Define a base charge point entity.""" + + def __init__(self, connector: Connector, evse_id: str) -> None: + """Initialize the entity.""" + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] + + self.evse_id = evse_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, evse_id)}, + name=chargepoint_name if chargepoint_name != "" else evse_id, + manufacturer="Blue Current", + model=connector.charge_points[evse_id][MODEL_TYPE], + ) + + super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json new file mode 100644 index 00000000000..bff8a057f08 --- /dev/null +++ b/homeassistant/components/blue_current/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blue_current", + "name": "Blue Current", + "codeowners": ["@Floris272", "@gleeuwen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/blue_current", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/bluecurrent/ha-bluecurrent/issues", + "requirements": ["bluecurrent-api==1.0.6"] +} diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py new file mode 100644 index 00000000000..326caa70f54 --- /dev/null +++ b/homeassistant/components/blue_current/sensor.py @@ -0,0 +1,296 @@ +"""Support for Blue Current sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CURRENCY_EURO, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN +from .entity import BlueCurrentEntity, ChargepointEntity + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + +SENSORS = ( + SensorEntityDescription( + key="actual_v1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="avg_voltage", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="total_kw", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + translation_key="total_kw", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_kwh", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + translation_key="actual_kwh", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="start_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="start_datetime", + ), + SensorEntityDescription( + key="stop_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="stop_datetime", + ), + SensorEntityDescription( + key="offline_since", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="offline_since", + ), + SensorEntityDescription( + key="total_cost", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + translation_key="total_cost", + ), + SensorEntityDescription( + key="vehicle_status", + icon="mdi:car", + device_class=SensorDeviceClass.ENUM, + options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], + translation_key="vehicle_status", + ), + SensorEntityDescription( + key="activity", + icon="mdi:ev-station", + device_class=SensorDeviceClass.ENUM, + options=["available", "charging", "unavailable", "error", "offline"], + translation_key="activity", + ), + SensorEntityDescription( + key="max_usage", + translation_key="max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="smartcharging_max_usage", + translation_key="smartcharging_max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="max_offline", + translation_key="max_offline", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_left", + translation_key="current_left", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +GRID_SENSORS = ( + SensorEntityDescription( + key="grid_actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_max_current", + state_class=SensorStateClass.MEASUREMENT, + ), +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current sensors.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + sensor_list: list[SensorEntity] = [] + for evse_id in connector.charge_points: + for sensor in SENSORS: + sensor_list.append(ChargePointSensor(connector, sensor, evse_id)) + + for grid_sensor in GRID_SENSORS: + sensor_list.append(GridSensor(connector, grid_sensor)) + + async_add_entities(sensor_list) + + +class ChargePointSensor(ChargepointEntity, SensorEntity): + """Define a charge point sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, evse_id) + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = f"{sensor.key}_{evse_id}" + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor from the latest data.""" + + new_value = self.connector.charge_points[self.evse_id].get(self.key) + + if new_value is not None: + if self.key in TIMESTAMP_KEYS and not ( + self._attr_native_value is None or self._attr_native_value < new_value + ): + return + self.has_value = True + self._attr_native_value = new_value + + elif self.key not in TIMESTAMP_KEYS: + self.has_value = False + + +class GridSensor(BlueCurrentEntity, SensorEntity): + """Define a grid sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, f"{DOMAIN}_grid_update") + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = sensor.key + + @callback + def update_from_latest_data(self) -> None: + """Update the grid sensor from the latest data.""" + + new_value = self.connector.grid.get(self.key) + + if new_value is not None: + self.has_value = True + self._attr_native_value = new_value + + else: + self.has_value = False diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json new file mode 100644 index 00000000000..10c114e5f1c --- /dev/null +++ b/homeassistant/components/blue_current/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "limit_reached": "Request limit reached", + "invalid_token": "Invalid token", + "no_cards_found": "No charge cards found", + "already_connected": "Already connected", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "activity": { + "name": "Activity", + "state": { + "available": "Available", + "charging": "Charging", + "unavailable": "Unavailable", + "error": "Error", + "offline": "Offline" + } + }, + "vehicle_status": { + "name": "Vehicle status", + "state": { + "standby": "Standby", + "vehicle_detected": "Detected", + "ready": "Ready", + "no_power": "No power", + "vehicle_error": "Error" + } + }, + "actual_v1": { + "name": "Voltage phase 1" + }, + "actual_v2": { + "name": "Voltage phase 2" + }, + "actual_v3": { + "name": "Voltage phase 3" + }, + "avg_voltage": { + "name": "Average voltage" + }, + "actual_p1": { + "name": "Current phase 1" + }, + "actual_p2": { + "name": "Current phase 2" + }, + "actual_p3": { + "name": "Current phase 3" + }, + "avg_current": { + "name": "Average current" + }, + "total_kw": { + "name": "Total power" + }, + "actual_kwh": { + "name": "Energy usage" + }, + "start_datetime": { + "name": "Started on" + }, + "stop_datetime": { + "name": "Stopped on" + }, + "offline_since": { + "name": "Offline since" + }, + "total_cost": { + "name": "Total cost" + }, + "max_usage": { + "name": "Max usage" + }, + "smartcharging_max_usage": { + "name": "Smart charging max usage" + }, + "max_offline": { + "name": "Offline max usage" + }, + "current_left": { + "name": "Remaining current" + }, + "grid_actual_p1": { + "name": "Grid current phase 1" + }, + "grid_actual_p2": { + "name": "Grid current phase 2" + }, + "grid_actual_p3": { + "name": "Grid current phase 3" + }, + "grid_avg_current": { + "name": "Average grid current" + }, + "grid_max_current": { + "name": "Max grid current" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9fcc3ea93b9..eaeff88f5ed 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -66,6 +66,7 @@ FLOWS = { "balboa", "blebox", "blink", + "blue_current", "bluemaestro", "bluetooth", "bmw_connected_drive", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 61cb665af2f..5c19a418853 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -650,6 +650,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "blue_current": { + "name": "Blue Current", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "bluemaestro": { "name": "BlueMaestro", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 45ad5207078..3aa2e5dfdbf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -570,6 +570,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blue_current.*] +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.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4c542511f67..0e82fbedfc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -552,6 +552,9 @@ blinkpy==0.22.4 # homeassistant.components.bitcoin blockchain==1.4.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c25939d2063..e5483fce899 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -468,6 +468,9 @@ blebox-uniapi==2.2.0 # homeassistant.components.blink blinkpy==0.22.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py new file mode 100644 index 00000000000..901c776a894 --- /dev/null +++ b/tests/components/blue_current/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Blue Current integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bluecurrent_api import Client + +from homeassistant.components.blue_current import DOMAIN, Connector +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, platform, data: dict, grid=None +) -> MockConfigEntry: + """Set up the Blue Current integration in Home Assistant.""" + + if grid is None: + grid = {} + + def init( + self: Connector, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Mock grid and charge_points.""" + + self.config = config + self.hass = hass + self.client = client + self.charge_points = data + self.grid = grid + self.available = True + + with patch( + "homeassistant.components.blue_current.PLATFORMS", [platform] + ), patch.object(Connector, "__init__", init), patch( + "homeassistant.components.blue_current.Client", autospec=True + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_value_update_101") + return config_entry diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py new file mode 100644 index 00000000000..c510aeada4f --- /dev/null +++ b/tests/components/blue_current/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the Blue Current config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.blue_current.config_flow import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +from homeassistant.core import HomeAssistant + + +async def test_form(hass: HomeAssistant) -> None: + """Test if the form is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + +async def test_user(hass: HomeAssistant) -> None: + """Test if the api token is set.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidApiToken(), "invalid_token"), + (RequestLimitReached(), "limit_reached"), + (AlreadyConnected(), "already_connected"), + (Exception(), "unknown"), + (WebsocketError(), "cannot_connect"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test user initialized flow with invalid username.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"]["base"] == message + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py new file mode 100644 index 00000000000..fe40f58077f --- /dev/null +++ b/tests/components/blue_current/test_init.py @@ -0,0 +1,185 @@ +"""Test Blue Current Init Component.""" + +from datetime import timedelta +from unittest.mock import patch + +from bluecurrent_api.client import Client +from bluecurrent_api.exceptions import RequestLimitReached, WebsocketError +import pytest + +from homeassistant.components.blue_current import DOMAIN, Connector, async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + config_entry = await init_integration(hass, "sensor", {}) + assert config_entry.state == ConfigEntryState.LOADED + assert isinstance(hass.data[DOMAIN][config_entry.entry_id], Connector) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert hass.data[DOMAIN] == {} + + +async def test_config_not_ready(hass: HomeAssistant) -> None: + """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketError.""" + with patch( + "bluecurrent_api.Client.connect", + side_effect=WebsocketError, + ), pytest.raises(ConfigEntryNotReady): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await async_setup_entry(hass, config_entry) + + +async def test_on_data(hass: HomeAssistant) -> None: + """Test on_data.""" + + await init_integration(hass, "sensor", {}) + + with patch( + "homeassistant.components.blue_current.async_dispatcher_send" + ) as test_async_dispatcher_send: + connector: Connector = hass.data[DOMAIN]["uuid"] + + # test CHARGE_POINTS + data = { + "object": "CHARGE_POINTS", + "data": [{"evse_id": "101", "model_type": "hidden", "name": ""}], + } + await connector.on_data(data) + assert connector.charge_points == {"101": {"model_type": "hidden", "name": ""}} + + # test CH_STATUS + data2 = { + "object": "CH_STATUS", + "data": { + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + "evse_id": "101", + }, + } + await connector.on_data(data2) + assert connector.charge_points == { + "101": { + "model_type": "hidden", + "name": "", + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + } + } + + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test GRID_STATUS + data3 = { + "object": "GRID_STATUS", + "data": { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + }, + } + await connector.on_data(data3) + assert connector.grid == { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + } + test_async_dispatcher_send.assert_called_with(hass, "blue_current_grid_update") + + +async def test_start_loop(hass: HomeAssistant) -> None: + """Tests start_loop.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "bluecurrent_api.Client.start_loop", + side_effect=WebsocketError("unknown command"), + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + with patch( + "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + +async def test_reconnect(hass: HomeAssistant) -> None: + """Tests reconnect.""" + + with patch("bluecurrent_api.Client.connect"), patch( + "bluecurrent_api.Client.connect", side_effect=WebsocketError + ), patch( + "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) + ), patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + await connector.reconnect() + + test_async_call_later.assert_called_with(hass, 20, connector.reconnect) + + with patch("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + await connector.reconnect() + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py new file mode 100644 index 00000000000..a4bcbfcda00 --- /dev/null +++ b/tests/components/blue_current/test_sensor.py @@ -0,0 +1,181 @@ +"""The tests for Blue current sensors.""" +from datetime import datetime +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + + +charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + "activity": "available", + "start_datetime": datetime.strptime("20211118 14:12:23+08:00", "%Y%m%d %H:%M:%S%z"), + "stop_datetime": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "offline_since": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "total_cost": 13.32, + "avg_current": 16, + "avg_voltage": 15.7, + "total_kw": 251.2, + "vehicle_status": "standby", + "actual_kwh": 11, + "max_usage": 10, + "max_offline": 7, + "smartcharging_max_usage": 6, + "current_left": 10, +} + +data: dict[str, Any] = { + "101": { + "model_type": "hidden", + "evse_id": "101", + "name": "", + **charge_point, + } +} + + +charge_point_entity_ids = { + "voltage_phase_1": "actual_v1", + "voltage_phase_2": "actual_v2", + "voltage_phase_3": "actual_v3", + "current_phase_1": "actual_p1", + "current_phase_2": "actual_p2", + "current_phase_3": "actual_p3", + "activity": "activity", + "started_on": "start_datetime", + "stopped_on": "stop_datetime", + "offline_since": "offline_since", + "total_cost": "total_cost", + "average_current": "avg_current", + "average_voltage": "avg_voltage", + "total_power": "total_kw", + "vehicle_status": "vehicle_status", + "energy_usage": "actual_kwh", + "max_usage": "max_usage", + "offline_max_usage": "max_offline", + "smart_charging_max_usage": "smartcharging_max_usage", + "remaining_current": "current_left", +} + +grid = { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + "grid_max_current": 15, + "grid_avg_current": 13.7, +} + +grid_entity_ids = { + "grid_current_phase_1": "grid_actual_p1", + "grid_current_phase_2": "grid_actual_p2", + "grid_current_phase_3": "grid_actual_p3", + "max_grid_current": "grid_max_current", + "average_grid_current": "grid_avg_current", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the underlying sensors.""" + await init_integration(hass, "sensor", data, grid) + + entity_registry = er.async_get(hass) + for entity_id, key in charge_point_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.101_{entity_id}") + assert entry + assert entry.unique_id == f"{key}_101" + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + + value = charge_point[key] + + if key in TIMESTAMP_KEYS: + assert datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") == value + else: + assert state.state == str(value) + + for entity_id, key in grid_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.{entity_id}") + assert entry + assert entry.unique_id == key + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.{entity_id}") + assert state is not None + assert state.state == str(grid[key]) + + sensors = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) + len(grid.keys()) == len(sensors) + + +async def test_sensor_update(hass: HomeAssistant) -> None: + """Test if the sensors get updated when there is new data.""" + await init_integration(hass, "sensor", data, grid) + key = "avg_voltage" + entity_id = "average_voltage" + timestamp_key = "start_datetime" + timestamp_entity_id = "started_on" + grid_key = "grid_avg_current" + grid_entity_id = "average_grid_current" + + connector: Connector = hass.data["blue_current"]["uuid"] + + connector.charge_points = {"101": {key: 20, timestamp_key: None}} + connector.grid = {grid_key: 20} + async_dispatcher_send(hass, "blue_current_value_update_101") + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_grid_update") + await hass.async_block_till_done() + + # test data updated + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + assert state.state == str(20) + + # grid + state = hass.states.get(f"sensor.{grid_entity_id}") + assert state + assert state.state == str(20) + + # test unavailable + state = hass.states.get("sensor.101_energy_usage") + assert state + assert state.state == "unavailable" + + # test if timestamp keeps old value + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) + + # test if older timestamp is ignored + connector.charge_points = { + "101": { + timestamp_key: datetime.strptime( + "20211118 14:11:23+08:00", "%Y%m%d %H:%M:%S%z" + ) + } + } + async_dispatcher_send(hass, "blue_current_value_update_101") + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) From d1d5c50b73cec8d595db3efaeecc43dfec5a9be3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 22 Dec 2023 16:53:51 +0100 Subject: [PATCH 631/927] Add full test coverage to Trafikverket Train (#106247) * Add full test coverage for Trafikverket Train * Coordinator full coverage * Now full coverage * Add util --- .coveragerc | 3 - .../components/trafikverket_train/conftest.py | 2 +- .../snapshots/test_init.ambr | 16 ++ .../snapshots/test_sensor.ambr | 15 ++ .../trafikverket_train/test_config_flow.py | 56 ++++--- .../trafikverket_train/test_init.py | 143 ++++++++++++++++++ .../trafikverket_train/test_sensor.py | 85 ++++++++++- .../trafikverket_train/test_util.py | 25 +++ 8 files changed, 317 insertions(+), 28 deletions(-) create mode 100644 tests/components/trafikverket_train/snapshots/test_init.ambr create mode 100644 tests/components/trafikverket_train/test_init.py create mode 100644 tests/components/trafikverket_train/test_util.py diff --git a/.coveragerc b/.coveragerc index 32622accd9a..83707b6b9f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1393,9 +1393,6 @@ omit = homeassistant/components/tradfri/light.py homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py - homeassistant/components/trafikverket_train/__init__.py - homeassistant/components/trafikverket_train/coordinator.py - homeassistant/components/trafikverket_train/util.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index dd9721a694e..423dee541d2 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -151,7 +151,7 @@ def fixture_get_train_stop() -> TrainStop: id=13, canceled=False, advertised_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), - estimated_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=None, time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), other_information=None, deviations=None, diff --git a/tests/components/trafikverket_train/snapshots/test_init.ambr b/tests/components/trafikverket_train/snapshots/test_init.ambr new file mode 100644 index 00000000000..c32995fdb76 --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_init.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_auth_failed + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': '321', + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/snapshots/test_sensor.ambr b/tests/components/trafikverket_train/snapshots/test_sensor.ambr index 1fd0ba8552f..6ea0168926e 100644 --- a/tests/components/trafikverket_train/snapshots/test_sensor.ambr +++ b/tests/components/trafikverket_train/snapshots/test_sensor.ambr @@ -214,3 +214,18 @@ 'state': '2023-05-01T11:00:00+00:00', }) # --- +# name: test_sensor_update_auth_failure + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': "stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index c31d05bd038..f56aee163bc 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -11,6 +11,7 @@ from pytrafikverket.exceptions import ( NoTrainStationFound, UnknownError, ) +from pytrafikverket.trafikverket_train import TrainStop from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -442,7 +443,11 @@ async def test_reauth_flow_error_departures( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -459,36 +464,41 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.async_setup_entry", - return_value=True, + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": "SJ Regionaltåg"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": "SJ Regionaltåg"}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": "SJ Regionaltåg"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": "SJ Regionaltåg"} - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": ""}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": ""}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": None} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": None} diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py new file mode 100644 index 00000000000..74b6f30ce61 --- /dev/null +++ b/tests/components/trafikverket_train/test_init.py @@ -0,0 +1,143 @@ +"""Test for Trafikverket Train component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant import config_entries +from homeassistant.components.trafikverket_train.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, get_trains: list[TrainStop]) -> None: + """Test unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ) as mock_tv_train: + 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_tv_train.mock_calls) == 1 + + 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 + + +async def test_auth_failed( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test authentication failed.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=InvalidAuthentication, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + active_flows = entry.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_no_stations( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test stations are missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=NoTrainStationFound, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, + entity_registry: EntityRegistry, +) -> None: + """Test migration of entity unique id in old format.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + entity = entity_registry.async_get_or_create( + DOMAIN, + "sensor", + "incorrect_unique_id", + config_entry=entry, + original_name="Stockholm C to Uppsala C", + ) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + entity = entity_registry.async_get(entity.entity_id) + assert entity.unique_id == f"{entry.entry_id}-departure_time" diff --git a/tests/components/trafikverket_train/test_sensor.py b/tests/components/trafikverket_train/test_sensor.py index 8378dc0179e..819433a6b9c 100644 --- a/tests/components/trafikverket_train/test_sensor.py +++ b/tests/components/trafikverket_train/test_sensor.py @@ -5,10 +5,12 @@ from datetime import timedelta from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainAnnouncementFound from pytrafikverket.trafikverket_train import TrainStop from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed @@ -72,3 +74,84 @@ async def test_sensor_single_stop( assert state.state == "2023-05-01T11:00:00+00:00" assert state == snapshot + + +async def test_sensor_update_auth_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with authentication update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=InvalidAuthentication, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=InvalidAuthentication, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + active_flows = load_int.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_sensor_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=NoTrainAnnouncementFound, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=NoTrainAnnouncementFound, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_update_failure_no_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure from empty state.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=None, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/trafikverket_train/test_util.py b/tests/components/trafikverket_train/test_util.py new file mode 100644 index 00000000000..e978917adca --- /dev/null +++ b/tests/components/trafikverket_train/test_util.py @@ -0,0 +1,25 @@ +"""The test for the Trafikverket train utils.""" +from __future__ import annotations + +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.trafikverket_train.util import next_departuredate +from homeassistant.const import WEEKDAYS +from homeassistant.util import dt as dt_util + + +async def test_sensor_next( + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Trafikverket Train utils.""" + + assert next_departuredate(WEEKDAYS) == dt_util.now().date() + freezer.move_to(datetime(2023, 12, 22)) # Friday + assert ( + next_departuredate(["mon", "tue", "wed", "thu"]) + == datetime(2023, 12, 25).date() + ) + freezer.move_to(datetime(2023, 12, 25)) # Monday + assert next_departuredate(["fri", "sat", "sun"]) == datetime(2023, 12, 29).date() From 181190d22db72823551e572030e1db1766fb255a Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 22 Dec 2023 17:11:49 +0100 Subject: [PATCH 632/927] Log when swiss_public_transport loses connection (#106200) * better handling of sensor init state * sensor.py use integrated attr Co-authored-by: Joost Lekkerkerker * use parents implementation for availability Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/swiss_public_transport/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8365afebaa7..0a69cf12085 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -156,4 +156,11 @@ class SwissPublicTransportSensor(SensorEntity): if not self._remaining_time or self._remaining_time.total_seconds() < 0: await self._opendata.async_get_data() except OpendataTransportError: - _LOGGER.error("Unable to retrieve data from transport.opendata.ch") + self._attr_available = False + _LOGGER.warning( + "Unable to connect and retrieve data from transport.opendata.ch" + ) + else: + if not self._attr_available: + self._attr_available = True + _LOGGER.info("Connection established with transport.opendata.ch") From 32a5345a8570b9c19e87691de36989f5809ce043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Fri, 22 Dec 2023 17:44:52 +0100 Subject: [PATCH 633/927] Add foscam coordinator (#92665) * Add foscam coordinator * Code cleanup * Coordinator cleanup * Coordinator cleanup * Replace async_timeout with asyncio.timeout * Ignore coordinator (requires external device) --------- Co-authored-by: G Johansson --- .coveragerc | 1 + CODEOWNERS | 4 +- homeassistant/components/foscam/__init__.py | 17 ++++++- homeassistant/components/foscam/camera.py | 31 ++++++------ .../components/foscam/coordinator.py | 47 +++++++++++++++++++ homeassistant/components/foscam/manifest.json | 2 +- 6 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/foscam/coordinator.py diff --git a/.coveragerc b/.coveragerc index 83707b6b9f1..fe2ba33b0aa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -421,6 +421,7 @@ omit = homeassistant/components/fortios/device_tracker.py homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py + homeassistant/components/foscam/coordinator.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index 052d3b9258e..c5ac30ea6df 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -418,8 +418,8 @@ build.json @home-assistant/supervisor /homeassistant/components/forked_daapd/ @uvjustin /tests/components/forked_daapd/ @uvjustin /homeassistant/components/fortios/ @kimfrellsen -/homeassistant/components/foscam/ @skgsergio -/tests/components/foscam/ @skgsergio +/homeassistant/components/foscam/ @skgsergio @krmarien +/tests/components/foscam/ @skgsergio @krmarien /homeassistant/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index ef88d0f671a..057ef4dbe8c 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -15,15 +15,28 @@ from homeassistant.helpers.entity_registry import async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .coordinator import FoscamCoordinator PLATFORMS = [Platform.CAMERA] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up foscam from a config entry.""" - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + session = FoscamCamera( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + verbose=False, + ) + coordinator = FoscamCoordinator(hass, session) + + 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 diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 384aea4c5fa..c07ddfd9bfb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -3,16 +3,16 @@ from __future__ import annotations import asyncio -from libpyfoscam import FoscamCamera import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_RTSP_PORT, @@ -22,6 +22,7 @@ from .const import ( SERVICE_PTZ, SERVICE_PTZ_PRESET, ) +from .coordinator import FoscamCoordinator DIR_UP = "up" DIR_DOWN = "down" @@ -88,28 +89,27 @@ async def async_setup_entry( "async_perform_ptz_preset", ) - camera = FoscamCamera( - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - verbose=False, - ) + coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([HassFoscamCamera(camera, config_entry)]) + async_add_entities([HassFoscamCamera(coordinator, config_entry)]) -class HassFoscamCamera(Camera): +class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): """An implementation of a Foscam IP camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: + def __init__( + self, + coordinator: FoscamCoordinator, + config_entry: ConfigEntry, + ) -> None: """Initialize a Foscam camera.""" - super().__init__() + super().__init__(coordinator) + Camera.__init__(self) - self._foscam_session = camera + self._foscam_session = coordinator.session self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] @@ -125,6 +125,9 @@ class HassFoscamCamera(Camera): async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" # Get motion detection status + + await super().async_added_to_hass() + ret, response = await self.hass.async_add_executor_job( self._foscam_session.get_motion_detect_config ) diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py new file mode 100644 index 00000000000..063d5235c04 --- /dev/null +++ b/homeassistant/components/foscam/coordinator.py @@ -0,0 +1,47 @@ +"""The foscam coordinator object.""" + +import asyncio +from datetime import timedelta +from typing import Any + +from libpyfoscam import FoscamCamera + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER + + +class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Foscam coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + session: FoscamCamera, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.session = session + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + + async with asyncio.timeout(30): + data = {} + ret, dev_info = await self.hass.async_add_executor_job( + self.session.get_dev_info + ) + if ret == 0: + data["dev_info"] = dev_info + + all_info = await self.hass.async_add_executor_job( + self.session.get_product_all_info + ) + data["product_info"] = all_info[1] + return data diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index fc7cbb72e3c..da4e9f53af4 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -1,7 +1,7 @@ { "domain": "foscam", "name": "Foscam", - "codeowners": ["@skgsergio"], + "codeowners": ["@skgsergio", "@krmarien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", From 656d0696bb55a028d87f56e50ddb7cc41767988b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 22 Dec 2023 09:49:41 -0800 Subject: [PATCH 634/927] Add support for re-ordering Google Tasks (#104769) * Add reorder and task ordering * Remove un-needed js id code * Revert dead code deletion * Remove reverted test and dead logger * Update comment name --- homeassistant/components/google_tasks/api.py | 15 ++++ homeassistant/components/google_tasks/todo.py | 8 ++ .../google_tasks/snapshots/test_todo.ambr | 50 ++++++++++++ tests/components/google_tasks/test_todo.py | 81 +++++++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 5dd7156702f..2658fdedc59 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -126,6 +126,21 @@ class AsyncConfigEntryAuth: ) await self._execute(batch) + async def move( + self, + task_list_id: str, + task_id: str, + previous: str | None, + ) -> None: + """Move a task resource to a specific position within the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().move( + tasklist=task_list_id, + task=task_id, + previous=previous, + ) + await self._execute(cmd) + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 130c0d2cc01..cf3f84e9a0d 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -91,6 +91,7 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) @@ -138,6 +139,13 @@ class GoogleTaskTodoListEntity( await self.coordinator.api.delete(self._task_list_id, uids) await self.coordinator.async_refresh() + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order a To-do item.""" + await self.coordinator.api.move(self._task_list_id, uid, previous=previous_uid) + await self.coordinator.async_refresh() + def _order_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: """Order the task items response. diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 7d6eb920593..e30739551f3 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -32,6 +32,56 @@ 'POST', ) # --- +# name: test_move_todo_item[api_responses0] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].1 + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id-3/move?previous=some-task-id-1&alt=json', + 'POST', + ) +# --- +# name: test_move_todo_item[api_responses0].2 + None +# --- +# name: test_move_todo_item[api_responses0].3 + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].4 + None +# --- # name: test_parent_child_ordering[api_responses0] list([ dict({ diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 3329f89c1ca..bf9a3f03df0 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -74,6 +74,29 @@ LIST_TASKS_RESPONSE_MULTIPLE = { }, ], } +LIST_TASKS_RESPONSE_REORDER = { + "items": [ + { + "id": "some-task-id-2", + "title": "Milk", + "status": "needsAction", + "position": "00000000000000000002", + }, + { + "id": "some-task-id-1", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, + # Task 3 moved after task 1 + { + "id": "some-task-id-3", + "title": "Cheese", + "status": "needsAction", + "position": "000000000000000000011", + }, + ], +} # API responses when testing update methods UPDATE_API_RESPONSES = [ @@ -793,6 +816,64 @@ async def test_parent_child_ordering( assert items == snapshot +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + EMPTY_RESPONSE, # move + LIST_TASKS_RESPONSE_REORDER, # refresh after move + ] + ], +) +async def test_move_todo_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + hass_ws_client: WebSocketGenerator, + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for re-ordering a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + items = await ws_get_items() + assert items == snapshot + + # Move to second in the list + client = await hass_ws_client() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": ENTITY_ID, + "uid": "some-task-id-3", + "previous_uid": "some-task-id-1", + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + items = await ws_get_items() + assert items == snapshot + + @pytest.mark.parametrize( "api_responses", [ From c41173bb299a3db64c1ff7f088742dd6332d1fb3 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Fri, 22 Dec 2023 13:26:52 -0500 Subject: [PATCH 635/927] Add binary sensors to drop_connect integration (#106248) --- .../components/drop_connect/__init__.py | 2 +- .../components/drop_connect/binary_sensor.py | 138 +++++++++++++ .../components/drop_connect/strings.json | 7 + .../drop_connect/test_binary_sensor.py | 192 ++++++++++++++++++ 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/drop_connect/binary_sensor.py create mode 100644 tests/components/drop_connect/test_binary_sensor.py diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index 45978a48d9a..63aad855829 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -15,7 +15,7 @@ from .coordinator import DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py new file mode 100644 index 00000000000..4c392eb8ce1 --- /dev/null +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -0,0 +1,138 @@ +"""Support for DROP binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SALT_SENSOR, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +LEAK_ICON = "mdi:pipe-leak" +NOTIFICATION_ICON = "mdi:bell-ring" +PUMP_ICON = "mdi:water-pump" +SALT_ICON = "mdi:shaker" +WATER_ICON = "mdi:water" + +# Binary sensor type constants +LEAK_DETECTED = "leak" +PENDING_NOTIFICATION = "pending_notification" +PUMP_STATUS = "pump" +RESERVE_IN_USE = "reserve_in_use" +SALT_LOW = "salt" + + +@dataclass(kw_only=True, frozen=True) +class DROPBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes DROP binary sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], bool | None] + + +BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ + DROPBinarySensorEntityDescription( + key=LEAK_DETECTED, + translation_key=LEAK_DETECTED, + icon=LEAK_ICON, + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda device: device.drop_api.leak_detected(), + ), + DROPBinarySensorEntityDescription( + key=PENDING_NOTIFICATION, + translation_key=PENDING_NOTIFICATION, + icon=NOTIFICATION_ICON, + value_fn=lambda device: device.drop_api.notification_pending(), + ), + DROPBinarySensorEntityDescription( + key=SALT_LOW, + translation_key=SALT_LOW, + icon=SALT_ICON, + value_fn=lambda device: device.drop_api.salt_low(), + ), + DROPBinarySensorEntityDescription( + key=RESERVE_IN_USE, + translation_key=RESERVE_IN_USE, + icon=WATER_ICON, + value_fn=lambda device: device.drop_api.reserve_in_use(), + ), + DROPBinarySensorEntityDescription( + key=PUMP_STATUS, + translation_key=PUMP_STATUS, + icon=PUMP_ICON, + value_fn=lambda device: device.drop_api.pump_status(), + ), +] + +# Defines which binary sensors are used by each device type +DEVICE_BINARY_SENSORS: dict[str, list[str]] = { + DEV_HUB: [LEAK_DETECTED, PENDING_NOTIFICATION], + DEV_LEAK_DETECTOR: [LEAK_DETECTED], + DEV_PROTECTION_VALVE: [LEAK_DETECTED], + DEV_PUMP_CONTROLLER: [LEAK_DETECTED, PUMP_STATUS], + DEV_RO_FILTER: [LEAK_DETECTED], + DEV_SALT_SENSOR: [SALT_LOW], + DEV_SOFTENER: [RESERVE_IN_USE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP binary sensors from config entry.""" + _LOGGER.debug( + "Set up binary sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: + async_add_entities( + DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in BINARY_SENSORS + if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPBinarySensor(DROPEntity, BinarySensorEntity): + """Representation of a DROP binary sensor.""" + + entity_description: DROPBinarySensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator) == 1 diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 0674515412f..2f11cf29cf8 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -25,6 +25,13 @@ "cart1": { "name": "Cartridge 1 life remaining" }, "cart2": { "name": "Cartridge 2 life remaining" }, "cart3": { "name": "Cartridge 3 life remaining" } + }, + "binary_sensor": { + "leak": { "name": "Leak detected" }, + "pending_notification": { "name": "Notification unread" }, + "reserve_in_use": { "name": "Reserve capacity in use" }, + "salt": { "name": "Salt low" }, + "pump": { "name": "Pump status" } } } } diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py new file mode 100644 index 00000000000..ca94faeec5e --- /dev/null +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -0,0 +1,192 @@ +"""Test DROP binary sensor entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SALT, + TEST_DATA_SALT_RESET, + TEST_DATA_SALT_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_binary_sensors_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + pending_notifications_sensor_name = ( + "binary_sensor.hub_drop_1_c0ffee_notification_unread" + ) + hass.states.async_set(pending_notifications_sensor_name, STATE_UNKNOWN) + leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + pending_notifications = hass.states.get(pending_notifications_sensor_name) + assert pending_notifications.state == STATE_ON + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_OFF + + +async def test_binary_sensors_salt( + hass: HomeAssistant, config_entry_salt, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for salt sensors.""" + config_entry_salt.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + salt_sensor_name = "binary_sensor.salt_sensor_salt_low" + hass.states.async_set(salt_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) + await hass.async_block_till_done() + + salt = hass.states.get(salt_sensor_name) + assert salt.state == STATE_ON + + +async def test_binary_sensors_leak( + hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for leak detectors.""" + config_entry_leak.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.leak_detector_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + + +async def test_binary_sensors_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" + hass.states.async_set(reserve_in_use_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + reserve_in_use = hass.states.get(reserve_in_use_sensor_name) + assert reserve_in_use.state == STATE_ON + + +async def test_binary_sensors_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.protection_valve_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + + +async def test_binary_sensors_pump_controller( + hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for pump controllers.""" + config_entry_pump_controller.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.pump_controller_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + pump_sensor_name = "binary_sensor.pump_controller_pump_status" + hass.states.async_set(pump_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + pump = hass.states.get(pump_sensor_name) + assert pump.state == STATE_ON + + +async def test_binary_sensors_ro_filter( + hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for RO filters.""" + config_entry_ro_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.ro_filter_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON From 087eb86e3749dd2a206bc8c1e3f330f66e235c60 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 22 Dec 2023 19:48:07 +0100 Subject: [PATCH 636/927] Improve cloud binary sensor tests (#106238) * Clean up cloud binary sensor test * Test remove entity --- tests/components/cloud/test_binary_sensor.py | 76 +++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 7b090bd5eca..6505be1fe10 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -1,44 +1,68 @@ """Tests for the cloud binary sensor.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from hass_nabucasa.const import DISPATCH_REMOTE_CONNECT, DISPATCH_REMOTE_DISCONNECT +import pytest -from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -async def test_remote_connection_sensor(hass: HomeAssistant) -> None: +@pytest.fixture(autouse=True) +def mock_wait_until() -> Generator[None, None, None]: + """Mock WAIT_UNTIL_CHANGE to execute callback immediately.""" + with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): + yield + + +async def test_remote_connection_sensor( + hass: HomeAssistant, + cloud: MagicMock, + entity_registry: EntityRegistry, +) -> None: """Test the remote connection sensor.""" + entity_id = "binary_sensor.remote_ui" + cloud.remote.certificate = None + assert await async_setup_component(hass, "cloud", {"cloud": {}}) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.remote_ui") is None + assert hass.states.get(entity_id) is None - # Fake connection/discovery - await async_load_platform(hass, "binary_sensor", "cloud", {}, {"cloud": {}}) + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() - # Mock test env - cloud = hass.data["cloud"] = Mock() - cloud.remote.certificate = None - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.remote_ui") + state = hass.states.get(entity_id) assert state is not None assert state.state == "unavailable" - with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): - cloud.remote.is_connected = False - cloud.remote.certificate = object() - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + cloud.remote.is_connected = False + cloud.remote.certificate = object() + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "off" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" - cloud.remote.is_connected = True - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + cloud.remote.is_connected = True + cloud.client.dispatcher_message(DISPATCH_REMOTE_CONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "on" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + # Test that a state is not set if the entity is removed. + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + + cloud.remote.is_connected = False + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None From 3a744d374b00a93ad0e19d58649c0bd43c8443ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 22 Dec 2023 20:02:55 +0100 Subject: [PATCH 637/927] Add support for caching entity properties (#100601) --- homeassistant/components/template/image.py | 4 +- homeassistant/components/weather/__init__.py | 6 +- homeassistant/helpers/entity.py | 241 ++++++++++++++++--- tests/components/zha/test_registries.py | 20 +- tests/helpers/test_entity.py | 120 +++++++++ 5 files changed, 345 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 55a0e2fb72d..227109d59e2 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -94,9 +94,9 @@ class StateImageEntity(TemplateEntity, ImageEntity): @property def entity_picture(self) -> str | None: """Return entity picture.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 if self._entity_picture_template: - return TemplateEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return TemplateEntity.entity_picture.__get__(self) + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 return ImageEntity.entity_picture.fget(self) # type: ignore[attr-defined] @callback diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 899181f2b5f..993c5e9503b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -44,7 +44,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform import homeassistant.helpers.issue_registry as ir @@ -254,10 +254,10 @@ class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes weather entities.""" -class PostInitMeta(abc.ABCMeta): +class PostInitMeta(ABCCachedProperties): """Meta class which calls __post_init__ after __new__ and __init__.""" - def __call__(cls, *args: Any, **kwargs: Any) -> Any: + def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: N805 ruff bug, ruff does not understand this is a metaclass """Create an instance.""" instance: PostInit = super().__call__(*args, **kwargs) instance.__post_init__(*args, **kwargs) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9f5ff3dad52..4fa2d5c3e51 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1,10 +1,10 @@ """An abstract class for entities.""" from __future__ import annotations -from abc import ABC +from abc import ABCMeta import asyncio from collections import deque -from collections.abc import Coroutine, Iterable, Mapping, MutableMapping +from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping import dataclasses from datetime import timedelta from enum import Enum, auto @@ -26,7 +26,6 @@ from typing import ( import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -63,8 +62,11 @@ from .event import ( from .typing import UNDEFINED, EventType, StateType, UndefinedType if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from functools import cached_property + from .entity_platform import EntityPlatform +else: + from homeassistant.backports.functools import cached_property _T = TypeVar("_T") @@ -259,7 +261,177 @@ class CalculatedState: shadowed_attributes: Mapping[str, Any] -class Entity(ABC): +class CachedProperties(type): + """Metaclass which invalidates cached entity properties on write to _attr_. + + A class which has CachedProperties can optionally have a list of cached + properties, passed as cached_properties, which must be a set of strings. + - Each item in the cached_property set must be the name of a method decorated + with @cached_property + - For each item in the cached_property set, a property function with the + same name, prefixed with _attr_, will be created + - The property _attr_-property functions allow setting, getting and deleting + data, which will be stored in an attribute prefixed with __attr_ + - The _attr_-property setter will invalidate the @cached_property by calling + delattr on it + """ + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + cached_properties: set[str] | None = None, + **kwargs: Any, + ) -> Any: + """Start creating a new CachedProperties. + + Pop cached_properties and store it in the namespace. + """ + namespace["_CachedProperties__cached_properties"] = cached_properties or set() + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Finish creating a new CachedProperties. + + Wrap _attr_ for cached properties in property objects. + """ + + def deleter(name: str) -> Callable[[Any], None]: + """Create a deleter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _deleter(o: Any) -> None: + """Delete an _attr_ property. + + Does two things: + - Delete the __attr_ attribute + - Invalidate the cache of the cached property + + Raises AttributeError if the __attr_ attribute does not exist + """ + # Invalidate the cache of the cached property + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + # Delete the __attr_ attribute + delattr(o, private_attr_name) + + return _deleter + + def getter(name: str) -> Callable[[Any], Any]: + """Create a getter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _getter(o: Any) -> Any: + """Get an _attr_ property from the backing __attr attribute.""" + return getattr(o, private_attr_name) + + return _getter + + def setter(name: str) -> Callable[[Any, Any], None]: + """Create a setter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _setter(o: Any, val: Any) -> None: + """Set an _attr_ property to the backing __attr attribute. + + Also invalidates the corresponding cached_property by calling + delattr on it. + """ + setattr(o, private_attr_name, val) + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + + return _setter + + def make_property(name: str) -> property: + """Help create a property object.""" + return property(fget=getter(name), fset=setter(name), fdel=deleter(name)) + + def wrap_attr(cls: CachedProperties, property_name: str) -> None: + """Wrap a cached property's corresponding _attr in a property. + + If the class being created has an _attr class attribute, move it, and its + annotations, to the __attr attribute. + """ + attr_name = f"_attr_{property_name}" + private_attr_name = f"__attr_{property_name}" + # Check if an _attr_ class attribute exits and move it to __attr_. We check + # __dict__ here because we don't care about _attr_ class attributes in parents. + if attr_name in cls.__dict__: + setattr(cls, private_attr_name, getattr(cls, attr_name)) + annotations = cls.__annotations__ + if attr_name in annotations: + annotations[private_attr_name] = annotations.pop(attr_name) + # Create the _attr_ property + setattr(cls, attr_name, make_property(property_name)) + + cached_properties: set[str] = namespace["_CachedProperties__cached_properties"] + seen_props: set[str] = set() # Keep track of properties which have been handled + for property_name in cached_properties: + wrap_attr(cls, property_name) + seen_props.add(property_name) + + # Look for cached properties of parent classes where this class has + # corresponding _attr_ class attributes and re-wrap them. + for parent in cls.__mro__[:0:-1]: + if "_CachedProperties__cached_properties" not in parent.__dict__: + continue + cached_properties = getattr(parent, "_CachedProperties__cached_properties") + for property_name in cached_properties: + if property_name in seen_props: + continue + attr_name = f"_attr_{property_name}" + # Check if an _attr_ class attribute exits. We check __dict__ here because + # we don't care about _attr_ class attributes in parents. + if (attr_name) not in cls.__dict__: + continue + wrap_attr(cls, property_name) + seen_props.add(property_name) + + +class ABCCachedProperties(CachedProperties, ABCMeta): + """Add ABCMeta to CachedProperties.""" + + +CACHED_PROPERTIES_WITH_ATTR_ = { + "assumed_state", + "attribution", + "available", + "capability_attributes", + "device_class", + "device_info", + "entity_category", + "has_entity_name", + "entity_picture", + "entity_registry_enabled_default", + "entity_registry_visible_default", + "extra_state_attributes", + "force_update", + "icon", + "name", + "should_poll", + "state", + "supported_features", + "translation_key", + "unique_id", + "unit_of_measurement", +} + + +class Entity( + metaclass=ABCCachedProperties, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for Home Assistant entities.""" # SAFE TO OVERWRITE @@ -367,7 +539,7 @@ class Entity(ABC): cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes ) - @property + @cached_property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -375,7 +547,7 @@ class Entity(ABC): """ return self._attr_should_poll - @property + @cached_property def unique_id(self) -> str | None: """Return a unique ID.""" return self._attr_unique_id @@ -398,7 +570,7 @@ class Entity(ABC): return not self.name - @property + @cached_property def has_entity_name(self) -> bool: """Return if the name of the entity is describing only the entity itself.""" if hasattr(self, "_attr_has_entity_name"): @@ -479,10 +651,17 @@ class Entity(ABC): @property def suggested_object_id(self) -> str | None: """Return input for object id.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if self.__class__.name.fget is Entity.name.fget and self.platform: # type: ignore[attr-defined] + if ( + # Check our class has overridden the name property from Entity + # We need to use type.__getattribute__ to retrieve the underlying + # property or cached_property object instead of the property's + # value. + type.__getattribute__(self.__class__, "name") + is type.__getattribute__(Entity, "name") + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + and self.platform + ): name = self._name_internal( self._object_id_device_class_name, self.platform.object_id_platform_translations, @@ -491,7 +670,7 @@ class Entity(ABC): name = self.name return None if name is UNDEFINED else name - @property + @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" # The check for self.platform guards against integrations not using an @@ -503,12 +682,12 @@ class Entity(ABC): self.platform.platform_translations, ) - @property + @cached_property def state(self) -> StateType: """Return the state of the entity.""" return self._attr_state - @property + @cached_property def capability_attributes(self) -> Mapping[str, Any] | None: """Return the capability attributes. @@ -531,7 +710,7 @@ class Entity(ABC): """ return None - @property + @cached_property def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes. @@ -540,7 +719,7 @@ class Entity(ABC): """ return None - @property + @cached_property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. @@ -551,7 +730,7 @@ class Entity(ABC): return self._attr_extra_state_attributes return None - @property + @cached_property def device_info(self) -> DeviceInfo | None: """Return device specific attributes. @@ -559,7 +738,7 @@ class Entity(ABC): """ return self._attr_device_info - @property + @cached_property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -568,7 +747,7 @@ class Entity(ABC): return self.entity_description.device_class return None - @property + @cached_property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if hasattr(self, "_attr_unit_of_measurement"): @@ -577,7 +756,7 @@ class Entity(ABC): return self.entity_description.unit_of_measurement return None - @property + @cached_property def icon(self) -> str | None: """Return the icon to use in the frontend, if any.""" if hasattr(self, "_attr_icon"): @@ -586,22 +765,22 @@ class Entity(ABC): return self.entity_description.icon return None - @property + @cached_property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" return self._attr_entity_picture - @property + @cached_property def available(self) -> bool: """Return True if entity is available.""" return self._attr_available - @property + @cached_property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return self._attr_assumed_state - @property + @cached_property def force_update(self) -> bool: """Return True if state updates should be forced. @@ -614,12 +793,12 @@ class Entity(ABC): return self.entity_description.force_update return False - @property + @cached_property def supported_features(self) -> int | None: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added. @@ -631,7 +810,7 @@ class Entity(ABC): return self.entity_description.entity_registry_enabled_default return True - @property + @cached_property def entity_registry_visible_default(self) -> bool: """Return if the entity should be visible when first added. @@ -643,12 +822,12 @@ class Entity(ABC): return self.entity_description.entity_registry_visible_default return True - @property + @cached_property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution - @property + @cached_property def entity_category(self) -> EntityCategory | None: """Return the category of the entity, if any.""" if hasattr(self, "_attr_entity_category"): @@ -657,7 +836,7 @@ class Entity(ABC): return self.entity_description.entity_category return None - @property + @cached_property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 68ff116adea..80845cf9866 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -585,18 +585,18 @@ def test_quirk_classes() -> None: def test_entity_names() -> None: """Make sure that all handlers expose entities with valid names.""" - for _, entities in iter_all_rules(): - for entity in entities: - if hasattr(entity, "_attr_name"): + for _, entity_classes in iter_all_rules(): + for entity_class in entity_classes: + if hasattr(entity_class, "__attr_name"): # The entity has a name - assert isinstance(entity._attr_name, str) and entity._attr_name - elif hasattr(entity, "_attr_translation_key"): + assert (name := entity_class.__attr_name) and isinstance(name, str) + elif hasattr(entity_class, "__attr_translation_key"): assert ( - isinstance(entity._attr_translation_key, str) - and entity._attr_translation_key + isinstance(entity_class.__attr_translation_key, str) + and entity_class.__attr_translation_key ) - elif hasattr(entity, "_attr_device_class"): - assert entity._attr_device_class + elif hasattr(entity_class, "__attr_device_class"): + assert entity_class.__attr_device_class else: # The only exception (for now) is IASZone - assert entity is IASZone + assert entity_class is IASZone diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index c3021e397ee..2bf90660f31 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -13,6 +13,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, @@ -1905,3 +1906,122 @@ async def test_update_capabilities_too_often_cooldown( assert entry.supported_features == supported_features + 1 assert capabilities_too_often_warning not in caplog.text + + +@pytest.mark.parametrize( + ("property", "default_value", "values"), [("attribution", None, ["abcd", "efgh"])] +) +async def test_cached_entity_properties( + hass: HomeAssistant, property: str, default_value: Any, values: Any +) -> None: + """Test entity properties are cached.""" + ent1 = entity.Entity() + ent2 = entity.Entity() + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + # Test set + setattr(ent1, f"_attr_{property}", values[0]) + assert getattr(ent1, property) == values[0] + assert getattr(ent2, property) == default_value + + # Test update + setattr(ent1, f"_attr_{property}", values[1]) + assert getattr(ent1, property) == values[1] + assert getattr(ent2, property) == default_value + + # Test delete + delattr(ent1, f"_attr_{property}") + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + +async def test_cached_entity_property_delete_attr(hass: HomeAssistant) -> None: + """Test deleting an _attr corresponding to a cached property.""" + property = "has_entity_name" + + ent = entity.Entity() + assert not hasattr(ent, f"_attr_{property}") + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + setattr(ent, f"_attr_{property}", True) + assert getattr(ent, property) is True + + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + +async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> None: + """Test entity properties on class level work in derived classes.""" + property = "attribution" + values = ["abcd", "efgh"] + + class EntityWithClassAttribute1(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution = values[0] + + class EntityWithClassAttribute2(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class also redundantly marks the overridden _attr_ as cached. + """ + + _attr_attribution = values[0] + + class EntityWithClassAttribute3(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property. + """ + + def __init__(self): + self._attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + class EntityWithClassAttribute4(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property and the _attr_. + """ + + _attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + classes = ( + EntityWithClassAttribute1, + EntityWithClassAttribute2, + EntityWithClassAttribute3, + EntityWithClassAttribute4, + ) + + entities: list[tuple[entity.Entity, entity.Entity]] = [] + for cls in classes: + entities.append((cls(), cls())) + + for ent in entities: + assert getattr(ent[0], property) == values[0] + assert getattr(ent[1], property) == values[0] + + # Test update + for ent in entities: + setattr(ent[0], f"_attr_{property}", values[1]) + for ent in entities: + assert getattr(ent[0], property) == values[1] + assert getattr(ent[1], property) == values[0] From 7ba1736637a5cad9cd390a7109962a45bdfd2169 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 22 Dec 2023 21:22:16 +0100 Subject: [PATCH 638/927] Filter by supported features for todo services (#106241) --- homeassistant/components/todo/services.yaml | 18 ++++++++++++++++++ homeassistant/helpers/service.py | 2 ++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 8ecc9e0ec86..07f91e12e22 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -26,14 +26,23 @@ add_item: selector: text: due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM example: "2023-11-17" selector: date: due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM example: "2023-11-17 13:30:00" selector: datetime: description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM example: "A more complete description of the to-do item than that provided by the summary." selector: text: @@ -62,14 +71,23 @@ update_item: - needs_action - completed due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM example: "2023-11-17" selector: date: due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM example: "2023-11-17 13:30:00" selector: datetime: description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM example: "A more complete description of the to-do item than that provided by the summary." selector: text: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b7a81c3fb19..9af69acc6b2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: media_player, remote, siren, + todo, update, vacuum, water_heater, @@ -106,6 +107,7 @@ def _base_components() -> dict[str, ModuleType]: "media_player": media_player, "remote": remote, "siren": siren, + "todo": todo, "update": update, "vacuum": vacuum, "water_heater": water_heater, From a4357409c84d9d69320108089479478a572adcc6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 22 Dec 2023 22:58:59 +0100 Subject: [PATCH 639/927] Fix Shelly consumption_types (#106273) --- homeassistant/components/shelly/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 7d475bf5ef8..a43d9cb0bcb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -360,7 +360,9 @@ def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: """Return true if rpc channel consumption type is set to light.""" con_types = config["sys"].get("ui_data", {}).get("consumption_types") - return con_types is not None and con_types[channel].lower().startswith("light") + if con_types is None or len(con_types) <= channel: + return False + return cast(str, con_types[channel]).lower().startswith("light") def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: From 963347b9c580ed93cba319c82f9073f4cca4c509 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 22 Dec 2023 16:29:26 -0600 Subject: [PATCH 640/927] Reduce Life360 update time by fetching Places & Members in parallel (#106277) --- homeassistant/components/life360/coordinator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 755fa1b8124..4ef6e20d703 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime @@ -130,8 +131,10 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): for circle in await self._retrieve_data("get_circles"): circle_id = circle["id"] - circle_members = await self._retrieve_data("get_circle_members", circle_id) - circle_places = await self._retrieve_data("get_circle_places", circle_id) + circle_members, circle_places = await asyncio.gather( + self._retrieve_data("get_circle_members", circle_id), + self._retrieve_data("get_circle_places", circle_id), + ) data.circles[circle_id] = Life360Circle( circle["name"], From 634551dae091585d104293ae42779e60a177c439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Dec 2023 15:22:06 -1000 Subject: [PATCH 641/927] Add support for attribute caching to the humidifier platform (#106271) --- .../components/humidifier/__init__.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index d9c804279b2..79aaff7c06a 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta from enum import StrEnum from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -54,6 +54,12 @@ from .const import ( # noqa: F401 HumidifierEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) @@ -140,7 +146,20 @@ class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True device_class: HumidifierDeviceClass | None = None -class HumidifierEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "action", + "current_humidity", + "target_humidity", + "mode", + "available_modes", + "min_humidity", + "max_humidity", + "supported_features", +} + + +class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for humidifier entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -171,7 +190,7 @@ class HumidifierEntity(ToggleEntity): return data - @property + @cached_property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -200,22 +219,22 @@ class HumidifierEntity(ToggleEntity): return data - @property + @cached_property def action(self) -> HumidifierAction | None: """Return the current action.""" return self._attr_action - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -223,7 +242,7 @@ class HumidifierEntity(ToggleEntity): """ return self._attr_mode - @property + @cached_property def available_modes(self) -> list[str] | None: """Return a list of available modes. @@ -247,17 +266,17 @@ class HumidifierEntity(ToggleEntity): """Set new mode.""" await self.hass.async_add_executor_job(self.set_mode, mode) - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity - @property + @cached_property def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features From 5d2ddcb1d2e3e0032b57cb1f84f00550e7d6c500 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Dec 2023 15:22:18 -1000 Subject: [PATCH 642/927] Add support for attribute caching to the number platform (#106254) --- homeassistant/components/number/__init__.py | 34 +++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 631fc5fc96c..c1da287879f 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -7,7 +7,7 @@ import dataclasses from datetime import timedelta import logging from math import ceil, floor -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final import voluptuous as vol @@ -42,6 +42,11 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -153,7 +158,18 @@ def floor_decimal(value: float, precision: float = 0) -> float: return floor(value * factor) / factor -class NumberEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "native_max_value", + "native_min_value", + "native_step", + "mode", + "native_unit_of_measurement", + "native_value", +} + + +class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Number entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -238,7 +254,7 @@ class NumberEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -247,7 +263,7 @@ class NumberEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def native_min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_native_min_value"): @@ -267,7 +283,7 @@ class NumberEntity(Entity): self.native_min_value, floor_decimal, self.device_class ) - @property + @cached_property def native_max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_native_max_value"): @@ -287,7 +303,7 @@ class NumberEntity(Entity): self.native_max_value, ceil_decimal, self.device_class ) - @property + @cached_property def native_step(self) -> float | None: """Return the increment/decrement step.""" if ( @@ -316,7 +332,7 @@ class NumberEntity(Entity): step /= 10.0 return step - @property + @cached_property def mode(self) -> NumberMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -334,7 +350,7 @@ class NumberEntity(Entity): """Return the entity state.""" return self.value - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -362,7 +378,7 @@ class NumberEntity(Entity): return native_unit_of_measurement - @property + @cached_property def native_value(self) -> float | None: """Return the value reported by the number.""" return self._attr_native_value From 0ae4d017b909b5167f9c27051bfa578c87a58393 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 23 Dec 2023 04:25:20 -0500 Subject: [PATCH 643/927] Add subwoofer crossover support to Sonos Amp devices (#106290) --- homeassistant/components/sonos/number.py | 1 + homeassistant/components/sonos/speaker.py | 2 ++ homeassistant/components/sonos/strings.json | 3 ++ tests/components/sonos/conftest.py | 40 ++++++++++----------- tests/components/sonos/test_number.py | 31 ++++++++++++++++ 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 375ed58035b..c74c5933ecf 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -21,6 +21,7 @@ LEVEL_TYPES = { "bass": (-10, 10), "balance": (-100, 100), "treble": (-10, 10), + "sub_crossover": (50, 110), "sub_gain": (-15, 15), "surround_level": (-15, 15), "music_surround_level": (-15, 15), diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b73ca6a77e4..fea5b5de7de 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -154,6 +154,7 @@ class SonosSpeaker: self.dialog_level: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None + self.sub_crossover: int | None = None self.sub_gain: int | None = None self.surround_enabled: bool | None = None self.surround_mode: bool | None = None @@ -561,6 +562,7 @@ class SonosSpeaker: "audio_delay", "bass", "treble", + "sub_crossover", "sub_gain", "surround_level", "music_surround_level", diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb10167f1d0..6f45195c46b 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -36,6 +36,9 @@ "treble": { "name": "Treble" }, + "sub_crossover": { + "name": "Sub crossover frequency" + }, "sub_gain": { "name": "Sub gain" }, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 648ca12803c..8bd8224e726 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -108,8 +108,26 @@ def config_entry_fixture(): class MockSoCo(MagicMock): """Mock the Soco Object.""" + uid = "RINCON_test" + play_mode = "NORMAL" + mute = False + night_mode = True + dialog_level = True + loudness = True + volume = 19 audio_delay = 2 + balance = (61, 100) + bass = 1 + treble = -1 + mic_enabled = False + sub_crossover = None # Default to None for non-Amp devices + sub_enabled = False sub_gain = 5 + surround_enabled = True + surround_mode = True + surround_level = 3 + music_surround_level = 4 + soundbar_audio_input_format = "Dolby 5.1" @property def visible_zones(self): @@ -143,10 +161,7 @@ class SoCoMockFactory: mock_soco.mock_add_spec(SoCo) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": - mock_soco.uid = f"RINCON_test_{ip_address}" - else: - mock_soco.uid = "RINCON_test" - mock_soco.play_mode = "NORMAL" + mock_soco.uid += f"_{ip_address}" mock_soco.music_library = self.music_library mock_soco.get_current_track_info.return_value = self.current_track_info mock_soco.music_source_from_uri = SoCo.music_source_from_uri @@ -161,23 +176,6 @@ class SoCoMockFactory: mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) mock_soco.alarmClock = self.alarm_clock - mock_soco.mute = False - mock_soco.night_mode = True - mock_soco.dialog_level = True - mock_soco.loudness = True - mock_soco.volume = 19 - mock_soco.audio_delay = 2 - mock_soco.balance = (61, 100) - mock_soco.bass = 1 - mock_soco.treble = -1 - mock_soco.mic_enabled = False - mock_soco.sub_enabled = False - mock_soco.sub_gain = 5 - mock_soco.surround_enabled = True - mock_soco.surround_mode = True - mock_soco.surround_level = 3 - mock_soco.music_surround_level = 4 - mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = self.battery_info mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index 38456058d8a..d58b84ab6cb 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -6,6 +6,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +CROSSOVER_ENTITY = "number.zone_a_sub_crossover_frequency" + async def test_number_entities( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry @@ -62,3 +64,32 @@ async def test_number_entities( blocking=True, ) mock_sub_gain.assert_called_once_with(-8) + + # sub_crossover is only available on Sonos Amp devices, see test_amp_number_entities + assert CROSSOVER_ENTITY not in entity_registry.entities + + +async def test_amp_number_entities( + hass: HomeAssistant, async_setup_sonos, soco, entity_registry: er.EntityRegistry +) -> None: + """Test the sub_crossover feature only available on Sonos Amp devices. + + The sub_crossover value will be None on all other device types. + """ + with patch.object(soco, "sub_crossover", 50): + await async_setup_sonos() + + sub_crossover_number = entity_registry.entities[CROSSOVER_ENTITY] + sub_crossover_state = hass.states.get(sub_crossover_number.entity_id) + assert sub_crossover_state.state == "50" + + with patch.object( + type(soco), "sub_crossover", new_callable=PropertyMock + ) as mock_sub_crossover: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: sub_crossover_number.entity_id, "value": 110}, + blocking=True, + ) + mock_sub_crossover.assert_called_once_with(110) From 1e12c7fe12b18f3a7a8283ff2428950a742ba9a8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 23 Dec 2023 02:26:00 -0700 Subject: [PATCH 644/927] Fix bug with non-existent Notion bridge IDs (#106152) --- homeassistant/components/notion/__init__.py | 27 ++++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 036ef6e4f0e..406acd6aabd 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -290,17 +290,19 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): """Initialize the entity.""" super().__init__(coordinator) - bridge = self.coordinator.data.bridges[bridge_id] sensor = self.coordinator.data.sensors[sensor_id] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", model=str(sensor.hardware_revision), name=str(sensor.name).capitalize(), sw_version=sensor.firmware_version, - via_device=(DOMAIN, bridge.hardware_id), ) + if bridge := self._async_get_bridge(bridge_id): + self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id) + self._attr_extra_state_attributes = {} self._attr_unique_id = listener_id self._bridge_id = bridge_id @@ -322,6 +324,14 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): """Return the listener related to this entity.""" return self.coordinator.data.listeners[self._listener_id] + @callback + def _async_get_bridge(self, bridge_id: int) -> Bridge | None: + """Get a bridge by ID (if it exists).""" + if (bridge := self.coordinator.data.bridges.get(bridge_id)) is None: + LOGGER.debug("Entity references a non-existent bridge ID: %s", bridge_id) + return None + return bridge + @callback def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. @@ -330,13 +340,12 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): """ sensor = self.coordinator.data.sensors[self._sensor_id] - # If the sensor's bridge ID is the same as what we had before or if it points - # to a bridge that doesn't exist (which can happen due to a Notion API bug), - # return immediately: - if ( - self._bridge_id == sensor.bridge.id - or sensor.bridge.id not in self.coordinator.data.bridges - ): + # If the bridge ID hasn't changed, return: + if self._bridge_id == sensor.bridge.id: + return + + # If the bridge doesn't exist, return: + if (bridge := self._async_get_bridge(sensor.bridge.id)) is None: return self._bridge_id = sensor.bridge.id From 5156a93b9e2ba4b9624a81b3ddaa6856aa671366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Dec 2023 23:29:55 -1000 Subject: [PATCH 645/927] Add support for attribute caching to the update platform (#106261) --- homeassistant/components/update/__init__.py | 46 +++++++++++++++------ tests/components/update/test_init.py | 1 + 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 8597647fc18..d4ceff9dc24 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta from enum import StrEnum from functools import lru_cache import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol @@ -20,7 +20,7 @@ from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -42,6 +42,11 @@ from .const import ( UpdateEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(minutes=15) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -187,7 +192,24 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: return AwesomeVersion(latest_version) > installed_version -class UpdateEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "auto_update", + "installed_version", + "device_class", + "in_progress", + "latest_version", + "release_summary", + "release_url", + "supported_features", + "title", +} + + +class UpdateEntity( + RestoreEntity, + metaclass=ABCCachedProperties, + cached_properties=CACHED_PROPERTIES_WITH_ATTR_, +): """Representation of an update entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -208,12 +230,12 @@ class UpdateEntity(RestoreEntity): __skipped_version: str | None = None __in_progress: bool = False - @property + @cached_property def auto_update(self) -> bool: """Indicate if the device or service has auto update enabled.""" return self._attr_auto_update - @property + @cached_property def installed_version(self) -> str | None: """Version installed and in use.""" return self._attr_installed_version @@ -225,7 +247,7 @@ class UpdateEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -256,7 +278,7 @@ class UpdateEntity(RestoreEntity): f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png" ) - @property + @cached_property def in_progress(self) -> bool | int | None: """Update installation progress. @@ -267,12 +289,12 @@ class UpdateEntity(RestoreEntity): """ return self._attr_in_progress - @property + @cached_property def latest_version(self) -> str | None: """Latest version available for install.""" return self._attr_latest_version - @property + @cached_property def release_summary(self) -> str | None: """Summary of the release notes or changelog. @@ -281,17 +303,17 @@ class UpdateEntity(RestoreEntity): """ return self._attr_release_summary - @property + @cached_property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" return self._attr_release_url - @property + @cached_property def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def title(self) -> str | None: """Title of the software. diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 73f98c9e2db..629c6838654 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -128,6 +128,7 @@ async def test_update(hass: HomeAssistant) -> None: update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG + del update.device_class update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, From 1d0cee5e8a1445add234a7b92d4c395409b98059 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 23 Dec 2023 10:35:16 +0100 Subject: [PATCH 646/927] Improve cloud system health tests (#106235) --- tests/components/cloud/conftest.py | 31 +++++++++- tests/components/cloud/test_system_health.py | 60 ++++++++++---------- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 0de43c80e87..6eaca4906c0 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,5 +1,5 @@ """Fixtures for cloud tests.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable, Coroutine from typing import Any from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch @@ -15,6 +15,7 @@ import jwt import pytest from homeassistant.components.cloud import CloudClient, const, prefs +from homeassistant.util.dt import utcnow from . import mock_cloud, mock_cloud_prefs @@ -62,7 +63,8 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: f"{name}_server": server for name, server in DEFAULT_SERVERS[mode].items() } - mock_cloud.configure_mock(**default_values, **servers, **kwargs) + mock_cloud.configure_mock(**default_values, **servers) + mock_cloud.configure_mock(**kwargs) mock_cloud.mode = mode # Properties that we mock as attributes from the constructor. @@ -101,7 +103,15 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: claims = PropertyMock(side_effect=mock_claims) type(mock_cloud).claims = claims + def mock_is_connected() -> bool: + """Return True if we are connected.""" + return mock_cloud.iot.state == STATE_CONNECTED + + is_connected = PropertyMock(side_effect=mock_is_connected) + type(mock_cloud).is_connected = is_connected + # Properties that we mock as attributes. + mock_cloud.expiration_date = utcnow() mock_cloud.subscription_expired = False # Methods that we mock with a custom side effect. @@ -119,6 +129,23 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: yield mock_cloud +@pytest.fixture(name="set_cloud_prefs") +def set_cloud_prefs_fixture( + cloud: MagicMock, +) -> Callable[[dict[str, Any]], Coroutine[Any, Any, None]]: + """Fixture for cloud component.""" + + async def set_cloud_prefs(prefs_settings: dict[str, Any]) -> None: + """Set cloud prefs.""" + prefs_to_set = cloud.client.prefs.as_dict() + prefs_to_set.pop(prefs.PREF_ALEXA_DEFAULT_EXPOSE) + prefs_to_set.pop(prefs.PREF_GOOGLE_DEFAULT_EXPOSE) + prefs_to_set.update(prefs_settings) + await cloud.client.prefs.async_update(**prefs_to_set) + + return set_cloud_prefs + + @pytest.fixture(autouse=True) def mock_tts_cache_dir_autouse(mock_tts_cache_dir): """Mock the TTS cache dir with empty dir.""" diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index c540394b937..9f1af8aaeb4 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -1,20 +1,25 @@ """Test cloud system health.""" import asyncio -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from typing import Any +from unittest.mock import MagicMock from aiohttp import ClientError from hass_nabucasa.remote import CertificateStatus +from homeassistant.components.cloud import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker async def test_cloud_system_health( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test cloud system health.""" aioclient_mock.get("https://cloud.bla.com/status", text="") @@ -23,32 +28,27 @@ async def test_cloud_system_health( "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", exc=ClientError, ) - hass.config.components.add("cloud") assert await async_setup_component(hass, "system_health", {}) - now = utcnow() + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() - hass.data["cloud"] = Mock( - region="us-east-1", - user_pool_id="AAAA", - relayer_server="cloud.bla.com", - acme_server="cert-server", - is_logged_in=True, - remote=Mock( - is_connected=False, - snitun_server="us-west-1", - certificate_status=CertificateStatus.READY, - ), - expiration_date=now, - is_connected=True, - client=Mock( - relayer_region="xx-earth-616", - prefs=Mock( - remote_enabled=True, - alexa_enabled=True, - google_enabled=False, - instance_id="12345678901234567890", - ), - ), + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + {"alexa_enabled": True, "google_enabled": False, "remote_enabled": True} ) info = await get_system_health_info(hass, "cloud") @@ -59,8 +59,8 @@ async def test_cloud_system_health( assert info == { "logged_in": True, - "subscription_expiration": now, - "certificate_status": "ready", + "subscription_expiration": cloud.expiration_date, + "certificate_status": CertificateStatus.READY, "relayer_connected": True, "relayer_region": "xx-earth-616", "remote_enabled": True, @@ -71,5 +71,5 @@ async def test_cloud_system_health( "can_reach_cert_server": "ok", "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok", - "instance_id": "12345678901234567890", + "instance_id": cloud.client.prefs.instance_id, } From 3404bd4de5e34e0863530821c841cbcb1a9411f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 23 Dec 2023 10:45:20 +0100 Subject: [PATCH 647/927] Fix flaky Tailwind tests by fixing via_device (#106294) --- homeassistant/components/tailwind/__init__.py | 15 +++++++++++++++ homeassistant/components/tailwind/entity.py | 6 +----- .../tailwind/snapshots/test_binary_sensor.ambr | 4 ++-- .../components/tailwind/snapshots/test_cover.ambr | 4 ++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index b71fd1cd0fc..f4772050e5a 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator @@ -17,6 +18,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Register the Tailwind device, since other entities will have it as a parent. + # This prevents a child device being created before the parent ending up + # with a missing via_device. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.data.device_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index a97d74490dc..843cc600582 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -1,7 +1,7 @@ """Base entity for the Tailwind integration.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,10 +25,6 @@ class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): self._attr_unique_id = f"{coordinator.data.device_id}-{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data.device_id)}, - connections={(CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, - manufacturer="Tailwind", - model=coordinator.data.product, - sw_version=coordinator.data.firmware_version, ) diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index e3da11f28d1..68a503e7fc0 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -212,7 +212,7 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '10.10', - 'via_device_id': None, + 'via_device_id': , }) # --- # name: test_number_entities[binary_sensor.door_2_operational_status] @@ -284,6 +284,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '10.10', - 'via_device_id': None, + 'via_device_id': , }) # --- diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index 4e94c1084e4..e5d6306778f 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -69,7 +69,7 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '10.10', - 'via_device_id': None, + 'via_device_id': , }) # --- # name: test_cover_entities[cover.door_2] @@ -142,6 +142,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '10.10', - 'via_device_id': None, + 'via_device_id': , }) # --- From 20ba764d927c390e28432d98ea4ef62a67e0cff4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 10:46:23 +0100 Subject: [PATCH 648/927] Deprecate deprecated camera constants (#106095) --- homeassistant/components/camera/__init__.py | 23 +++++++++--- homeassistant/components/camera/const.py | 16 +++++++-- tests/components/camera/test_init.py | 39 +++++++++++++++++++-- tests/components/rtsp_to_webrtc/conftest.py | 2 +- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 528c2cef50a..f7ce0691efb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -51,6 +51,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval @@ -60,6 +65,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_STREAM_TYPE_HLS, + _DEPRECATED_STREAM_TYPE_WEB_RTC, CAMERA_IMAGE_TIMEOUT, CAMERA_STREAM_SOURCE_TIMEOUT, CONF_DURATION, @@ -70,8 +77,6 @@ from .const import ( # noqa: F401 PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, - STREAM_TYPE_HLS, - STREAM_TYPE_WEB_RTC, StreamType, ) from .img_util import scale_jpeg_camera_image @@ -105,8 +110,16 @@ class CameraEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Pleease use the CameraEntityFeature enum instead. -SUPPORT_ON_OFF: Final = 1 -SUPPORT_STREAM: Final = 2 +_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum( + CameraEntityFeature.ON_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum( + CameraEntityFeature.STREAM, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} @@ -215,7 +228,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if not camera.stream and camera.supported_features & SUPPORT_STREAM: + if not camera.stream and camera.supported_features & CameraEntityFeature.STREAM: camera.stream = await camera.async_create_stream() if camera.stream: return await camera.stream.async_get_image( diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index f745f60b51a..da41c0b9fab 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,7 +1,14 @@ """Constants for Camera component.""" from enum import StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "camera" DATA_CAMERA_PREFS: Final = "camera_prefs" @@ -36,5 +43,10 @@ class StreamType(StrEnum): # These constants are deprecated as of Home Assistant 2022.5 # Please use the StreamType enum instead. -STREAM_TYPE_HLS = "hls" -STREAM_TYPE_WEB_RTC = "web_rtc" +_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1") +_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1") + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 8e49e00e498..ca4c0fe9a52 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus import io +from types import ModuleType from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest @@ -26,6 +27,7 @@ from homeassistant.setup import async_setup_component from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg +from tests.common import import_and_test_deprecated_constant_enum from tests.typing import ClientSessionGenerator, WebSocketGenerator STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -939,7 +941,7 @@ async def test_use_stream_for_stills( # 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, + return_value=camera.CameraEntityFeature.STREAM, ): resp = await client.get("/api/camera_proxy/camera.demo_camera") await hass.async_block_till_done() @@ -953,7 +955,7 @@ async def test_use_stream_for_stills( "homeassistant.components.camera.create_stream" ) as mock_create_stream, patch( "homeassistant.components.demo.camera.DemoCamera.supported_features", - return_value=camera.SUPPORT_STREAM, + return_value=camera.CameraEntityFeature.STREAM, ), patch( "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", return_value=True, @@ -971,3 +973,36 @@ async def test_use_stream_for_stills( mock_stream.async_get_image.assert_called_once() assert resp.status == HTTPStatus.OK assert await resp.read() == b"stream_keyframe_image" + + +@pytest.mark.parametrize( + "enum", + list(camera.const.StreamType), +) +@pytest.mark.parametrize( + "module", + [camera, camera.const], +) +def test_deprecated_stream_type_constants( + caplog: pytest.LogCaptureFixture, + enum: camera.const.StreamType, + module: ModuleType, +) -> None: + """Test deprecated stream type constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STREAM_TYPE_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(camera.CameraEntityFeature), +) +def test_deprecated_support_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: camera.CameraEntityFeature, +) -> None: + """Test deprecated support constants.""" + import_and_test_deprecated_constant_enum( + caplog, camera, entity_feature, "SUPPORT_", "2025.1" + ) diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py index f6ee0d1a628..a8ce74624f8 100644 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -53,7 +53,7 @@ async def mock_camera(hass) -> AsyncGenerator[None, None]: return_value=STREAM_SOURCE, ), patch( "homeassistant.components.camera.Camera.supported_features", - return_value=camera.SUPPORT_STREAM, + return_value=camera.CameraEntityFeature.STREAM, ): yield From 321dc3984cb57b912c52a997f30b701cc4e6396e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 23 Dec 2023 10:56:51 +0100 Subject: [PATCH 649/927] Add significant Change support for humidifier (#106015) --- .../humidifier/significant_change.py | 60 +++++++++++++++++++ .../humidifier/test_significant_change.py | 53 ++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 homeassistant/components/humidifier/significant_change.py create mode 100644 tests/components/humidifier/test_significant_change.py diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py new file mode 100644 index 00000000000..7acc1033d3f --- /dev/null +++ b/homeassistant/components/humidifier/significant_change.py @@ -0,0 +1,60 @@ +"""Helper to test significant Humidifier state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + if attr_name in [ATTR_ACTION, ATTR_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/humidifier/test_significant_change.py b/tests/components/humidifier/test_significant_change.py new file mode 100644 index 00000000000..3d1b2a7e1ab --- /dev/null +++ b/tests/components/humidifier/test_significant_change.py @@ -0,0 +1,53 @@ +"""Test the Humidifier significant change platform.""" +import pytest + +from homeassistant.components.humidifier import ( + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +) +from homeassistant.components.humidifier.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Humidifier significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "old_value"}, False), + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "new_value"}, True), + ({ATTR_MODE: "old_value"}, {ATTR_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_ACTION: "old_value", ATTR_MODE: "old_value"}, + {ATTR_ACTION: "new_value", ATTR_MODE: "old_value"}, + True, + ), + # float attributes + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ({ATTR_CURRENT_HUMIDITY: "invalid"}, {ATTR_CURRENT_HUMIDITY: 60.0}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: "invalid"}, False), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 63.0}, True), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 62.9}, False), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From bb30bfa225b0b66dc0e4493c0f3d24a16a2587dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:04:05 -1000 Subject: [PATCH 650/927] Reduce zeroconf matcher complexity (#105880) --- homeassistant/components/zeroconf/__init__.py | 52 ++++++------------- homeassistant/loader.py | 18 +++++-- 2 files changed, 28 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index a20924b268a..e12a7599d4d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -33,6 +33,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, + ZeroconfMatcher, async_get_homekit, async_get_zeroconf, bind_hass, @@ -54,9 +55,6 @@ HOMEKIT_TYPES = [ ] _HOMEKIT_MODEL_SPLITS = (None, " ", "-") -# Top level keys we support matching against in properties that are always matched in -# lower case. ex: ZeroconfServiceInfo.name -LOWER_MATCH_ATTRS = {"name"} CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" @@ -74,6 +72,8 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] @@ -319,24 +319,6 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_data( - matcher: dict[str, str | dict[str, str]], match_data: dict[str, str] -) -> bool: - """Check a matcher to ensure all values in match_data match.""" - for key in LOWER_MATCH_ATTRS: - if key not in matcher: - continue - if key not in match_data: - return False - match_val = matcher[key] - if TYPE_CHECKING: - assert isinstance(match_val, str) - - if not _memorized_fnmatch(match_data[key], match_val): - return False - return True - - def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: """Check a matcher to ensure all values in props.""" return not any( @@ -365,7 +347,7 @@ class ZeroconfDiscovery: self, hass: HomeAssistant, zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], + zeroconf_types: dict[str, list[ZeroconfMatcher]], homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], ) -> None: @@ -496,27 +478,23 @@ class ZeroconfDiscovery: # discover it, we can stop here. return - match_data: dict[str, str] = {} - for key in LOWER_MATCH_ATTRS: - attr_value: str = getattr(info, key) - match_data[key] = attr_value.lower() + if not (matchers := self.zeroconf_types.get(service_type)): + return # Not all homekit types are currently used for discovery # so not all service type exist in zeroconf_types - for matcher in self.zeroconf_types.get(service_type, []): + for matcher in matchers: if len(matcher) > 1: - if not _match_against_data(matcher, match_data): + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): continue - if ATTR_PROPERTIES in matcher: - matcher_props = matcher[ATTR_PROPERTIES] - if TYPE_CHECKING: - assert isinstance(matcher_props, dict) - if not _match_against_props(matcher_props, props): - continue - matcher_domain = matcher["domain"] - if TYPE_CHECKING: - assert isinstance(matcher_domain, str) + matcher_domain = matcher[ATTR_DOMAIN] context = { "source": config_entries.SOURCE_ZEROCONF, } diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6fb538a5aef..0a44ccb05c9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -131,6 +131,14 @@ class HomeKitDiscoveredIntegration: always_discover: bool +class ZeroconfMatcher(TypedDict, total=False): + """Matcher for zeroconf.""" + + domain: str + name: str + properties: dict[str, str] + + class Manifest(TypedDict, total=False): """Integration manifest. @@ -374,7 +382,7 @@ async def async_get_application_credentials(hass: HomeAssistant) -> list[str]: ] -def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: +def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> ZeroconfMatcher: """Handle backwards compat with zeroconf matchers.""" entry_without_type: dict[str, Any] = entry.copy() del entry_without_type["type"] @@ -396,21 +404,21 @@ def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: else: prop_dict = entry_without_type["properties"] prop_dict[moved_prop] = value.lower() - return entry_without_type + return cast(ZeroconfMatcher, entry_without_type) async def async_get_zeroconf( hass: HomeAssistant, -) -> dict[str, list[dict[str, str | dict[str, str]]]]: +) -> dict[str, list[ZeroconfMatcher]]: """Return cached list of zeroconf types.""" - zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment] + zeroconf: dict[str, list[ZeroconfMatcher]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): if not integration.zeroconf: continue for entry in integration.zeroconf: - data: dict[str, str | dict[str, str]] = {"domain": integration.domain} + data: ZeroconfMatcher = {"domain": integration.domain} if isinstance(entry, dict): typ = entry["type"] data.update(async_process_zeroconf_match_dict(entry)) From 01da8a089fb679b622ca5e65d17b65b00b07d4e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Dec 2023 11:06:54 +0100 Subject: [PATCH 651/927] Bump github/codeql-action from 2.22.10 to 3.22.12 (#106221) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 74cb3826a6c..1dc36b9fa34 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.10 + uses: github/codeql-action/init@v3.22.12 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.10 + uses: github/codeql-action/analyze@v3.22.12 with: category: "/language:python" From d1174593f91ec5aacbab7f4405a3e24faed79d0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:08:35 -1000 Subject: [PATCH 652/927] Add support for attribute caching to the lock platform (#106275) --- homeassistant/components/lock/__init__.py | 34 +++++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index a9f31a3a410..ec462c3b993 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -6,7 +6,7 @@ from enum import IntFlag import functools as ft import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -39,6 +39,11 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" @@ -113,7 +118,18 @@ class LockEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lock entities.""" -class LockEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "changed_by", + "code_format", + "is_locked", + "is_locking", + "is_unlocking", + "is_jammed", + "supported_features", +} + + +class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lock entities.""" entity_description: LockEntityDescription @@ -143,12 +159,12 @@ class LockEntity(Entity): data[ATTR_CODE] = code return data - @property + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" return self._attr_code_format @@ -167,22 +183,22 @@ class LockEntity(Entity): self.__code_format_cmp = re.compile(self.code_format) return self.__code_format_cmp - @property + @cached_property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" return self._attr_is_locked - @property + @cached_property def is_locking(self) -> bool | None: """Return true if the lock is locking.""" return self._attr_is_locking - @property + @cached_property def is_unlocking(self) -> bool | None: """Return true if the lock is unlocking.""" return self._attr_is_unlocking - @property + @cached_property def is_jammed(self) -> bool | None: """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed @@ -250,7 +266,7 @@ class LockEntity(Entity): return None return STATE_LOCKED if locked else STATE_UNLOCKED - @property + @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" return self._attr_supported_features From 98dd69ba09129bb5bb31fc69c398707c4fd9603a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:09:18 -1000 Subject: [PATCH 653/927] Add support for attribute caching to the remote platform (#106274) --- homeassistant/components/remote/__init__.py | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index a85784a33a7..5d076c0768f 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -35,6 +35,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_ACTIVITY = "activity" @@ -174,7 +180,14 @@ class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes remote entities.""" -class RemoteEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "current_activity", + "activity_list", +} + + +class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for remote entities.""" entity_description: RemoteEntityDescription @@ -182,17 +195,17 @@ class RemoteEntity(ToggleEntity): _attr_current_activity: str | None = None _attr_supported_features: RemoteEntityFeature = RemoteEntityFeature(0) - @property + @cached_property def supported_features(self) -> RemoteEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def current_activity(self) -> str | None: """Active activity.""" return self._attr_current_activity - @property + @cached_property def activity_list(self) -> list[str] | None: """List of available activities.""" return self._attr_activity_list From ef0031cbcf340cdc7ea9c89de5156d3c3be9f0a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:09:46 -1000 Subject: [PATCH 654/927] Add support for attribute caching to the button platform (#106259) --- homeassistant/components/button/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 4ebe1df68a2..358348a8077 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +22,11 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, SERVICE_PRESS +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -78,7 +83,12 @@ class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): device_class: ButtonDeviceClass | None = None -class ButtonEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Button entity.""" entity_description: ButtonEntityDescription @@ -94,7 +104,7 @@ class ButtonEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): From ca7daa21fefef70b02c8152081a55ecb505b059a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:10:46 -1000 Subject: [PATCH 655/927] Add support for attribute caching to the text platform (#106262) --- homeassistant/components/text/__init__.py | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 8e20fdd33af..89fad759f8b 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import StrEnum import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -33,6 +33,11 @@ from .const import ( SERVICE_SET_VALUE, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -107,7 +112,16 @@ class TextEntityDescription(EntityDescription, frozen_or_thawed=True): pattern: str | None = None -class TextEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "mode", + "native_value", + "native_min", + "native_max", + "pattern", +} + + +class TextEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Text entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -156,7 +170,7 @@ class TextEntity(Entity): ) return self.native_value - @property + @cached_property def mode(self) -> TextMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -165,7 +179,7 @@ class TextEntity(Entity): return self.entity_description.mode return TextMode.TEXT - @property + @cached_property def native_min(self) -> int: """Return the minimum length of the value.""" if hasattr(self, "_attr_native_min"): @@ -180,7 +194,7 @@ class TextEntity(Entity): """Return the minimum length of the value.""" return max(self.native_min, 0) - @property + @cached_property def native_max(self) -> int: """Return the maximum length of the value.""" if hasattr(self, "_attr_native_max"): @@ -206,7 +220,7 @@ class TextEntity(Entity): self.__pattern_cmp = re.compile(self.pattern) return self.__pattern_cmp - @property + @cached_property def pattern(self) -> str | None: """Return the regex pattern that the value must match.""" if hasattr(self, "_attr_pattern"): @@ -215,7 +229,7 @@ class TextEntity(Entity): return self.entity_description.pattern return None - @property + @cached_property def native_value(self) -> str | None: """Return the value reported by the text.""" return self._attr_native_value From 1c8d96183297d119e5355fb0fdf6ad38b0ddf717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:11:14 -1000 Subject: [PATCH 656/927] Add support for attribute caching to ToggleEntity (#106272) --- homeassistant/helpers/entity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4fa2d5c3e51..95d003cd11c 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1496,7 +1496,12 @@ class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" -class ToggleEntity(Entity): +TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ = {"is_on"} + + +class ToggleEntity( + Entity, cached_properties=TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for entities that can be turned on and off.""" entity_description: ToggleEntityDescription @@ -1511,7 +1516,7 @@ class ToggleEntity(Entity): return None return STATE_ON if is_on else STATE_OFF - @property + @cached_property def is_on(self) -> bool | None: """Return True if entity is on.""" return self._attr_is_on From 97ed6570a73191430b1fee564154c72bbf11c064 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:11:50 -1000 Subject: [PATCH 657/927] Add support for attribute caching to the binary_sensor platform (#106253) --- .../components/binary_sensor/__init__.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 4372c0ee55b..3a32a1afb57 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta from enum import StrEnum from functools import partial import logging -from typing import Literal, final +from typing import TYPE_CHECKING, Literal, final import voluptuous as vol @@ -26,8 +26,14 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) + DOMAIN = "binary_sensor" SCAN_INTERVAL = timedelta(seconds=30) @@ -247,7 +253,13 @@ class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): device_class: BinarySensorDeviceClass | None = None -class BinarySensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "is_on", +} + + +class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a binary sensor.""" entity_description: BinarySensorEntityDescription @@ -270,7 +282,7 @@ class BinarySensorEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -279,7 +291,7 @@ class BinarySensorEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._attr_is_on From ff004a1c204fcfd24decbe14e1ad2cd6a6542c5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:13:02 -1000 Subject: [PATCH 658/927] Add support for attribute caching to the sensor platform (#106252) --- homeassistant/components/sensor/__init__.py | 37 +++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 993deae280a..ff66e42f466 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -10,7 +10,7 @@ from decimal import Decimal, InvalidOperation as DecimalInvalidOperation from functools import partial import logging from math import ceil, floor, isfinite, log10 -from typing import Any, Final, Self, cast, final +from typing import TYPE_CHECKING, Any, Final, Self, cast, final from typing_extensions import override @@ -92,6 +92,11 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -182,7 +187,19 @@ def _numeric_state_expected( return device_class is not None -class SensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "last_reset", + "native_unit_of_measurement", + "native_value", + "options", + "state_class", + "suggested_display_precision", + "suggested_unit_of_measurement", +} + + +class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -302,7 +319,7 @@ class SensorEntity(Entity): """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @property + @cached_property @override def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" @@ -323,7 +340,7 @@ class SensorEntity(Entity): self.suggested_display_precision, ) - @property + @cached_property def options(self) -> list[str] | None: """Return a set of possible options.""" if hasattr(self, "_attr_options"): @@ -332,7 +349,7 @@ class SensorEntity(Entity): return self.entity_description.options return None - @property + @cached_property def state_class(self) -> SensorStateClass | str | None: """Return the state class of this entity, if any.""" if hasattr(self, "_attr_state_class"): @@ -341,7 +358,7 @@ class SensorEntity(Entity): return self.entity_description.state_class return None - @property + @cached_property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if any.""" if hasattr(self, "_attr_last_reset"): @@ -424,12 +441,12 @@ class SensorEntity(Entity): return None - @property + @cached_property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self._attr_native_value - @property + @cached_property def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" if hasattr(self, "_attr_suggested_display_precision"): @@ -438,7 +455,7 @@ class SensorEntity(Entity): return self.entity_description.suggested_display_precision return None - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -447,7 +464,7 @@ class SensorEntity(Entity): return self.entity_description.native_unit_of_measurement return None - @property + @cached_property def suggested_unit_of_measurement(self) -> str | None: """Return the unit which should be used for the sensor's state. From 859e7972ac50c55711faed114e3207e520e710c7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 23 Dec 2023 11:24:32 +0100 Subject: [PATCH 659/927] Add significant Change support for vacuum (#106002) --- .../components/vacuum/significant_change.py | 58 +++++++++++++++++++ .../vacuum/test_significant_change.py | 51 ++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 homeassistant/components/vacuum/significant_change.py create mode 100644 tests/components/vacuum/test_significant_change.py diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py new file mode 100644 index 00000000000..3031d60305a --- /dev/null +++ b/homeassistant/components/vacuum/significant_change.py @@ -0,0 +1,58 @@ +"""Helper to test significant Vacuum state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + if attr_name != ATTR_BATTERY_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/vacuum/test_significant_change.py b/tests/components/vacuum/test_significant_change.py new file mode 100644 index 00000000000..5f46080fb8d --- /dev/null +++ b/tests/components/vacuum/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Vacuum significant change platform.""" +import pytest + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_ICON, + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +) +from homeassistant.components.vacuum.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Vacuum significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "old_value"}, False), + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "new_value"}, True), + # multiple attributes + ( + {ATTR_FAN_SPEED: "old_value", ATTR_BATTERY_LEVEL: 10.0}, + {ATTR_FAN_SPEED: "new_value", ATTR_BATTERY_LEVEL: 10.0}, + True, + ), + # float attributes + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 11.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 10.9}, False), + ({ATTR_BATTERY_LEVEL: "invalid"}, {ATTR_BATTERY_LEVEL: 10.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "new_value"}, False), + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Vacuum significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 060172fc24bde411fced44e0bb49aec6ec75c8a1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 11:25:39 +0100 Subject: [PATCH 660/927] Deprecate deprecated data entry flow constants (#106229) --- homeassistant/data_entry_flow.py | 36 ++++++++++++++----- .../components/osoenergy/test_config_flow.py | 18 +++++----- tests/test_data_entry_flow.py | 13 ++++++- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index b7b1a68e792..5c9c0ff1ce4 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterable, Mapping import copy from dataclasses import dataclass from enum import StrEnum +from functools import partial import logging from types import MappingProxyType from typing import Any, Required, TypedDict @@ -14,6 +15,11 @@ import voluptuous as vol from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .helpers.frame import report from .util import uuid as uuid_util @@ -34,14 +40,28 @@ class FlowResultType(StrEnum): # RESULT_TYPE_* is deprecated, to be removed in 2022.9 -RESULT_TYPE_FORM = "form" -RESULT_TYPE_CREATE_ENTRY = "create_entry" -RESULT_TYPE_ABORT = "abort" -RESULT_TYPE_EXTERNAL_STEP = "external" -RESULT_TYPE_EXTERNAL_STEP_DONE = "external_done" -RESULT_TYPE_SHOW_PROGRESS = "progress" -RESULT_TYPE_SHOW_PROGRESS_DONE = "progress_done" -RESULT_TYPE_MENU = "menu" +_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1") +_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum( + FlowResultType.CREATE_ENTRY, "2025.1" +) +_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1") +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP, "2025.1" +) +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py index d7250356ebe..5c7e0b3442c 100644 --- a/tests/components/osoenergy/test_config_flow.py +++ b/tests/components/osoenergy/test_config_flow.py @@ -22,7 +22,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( @@ -37,7 +37,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_USER_EMAIL assert result2["data"] == { CONF_API_KEY: SUBSCRIPTION_KEY, @@ -70,7 +70,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: data=mock_config.data, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} with patch( @@ -86,7 +86,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY - assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["type"] == data_entry_flow.FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -112,7 +112,7 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -122,7 +122,7 @@ async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( @@ -134,7 +134,7 @@ async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: {CONF_API_KEY: SUBSCRIPTION_KEY}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "invalid_auth"} @@ -147,7 +147,7 @@ async def test_user_flow_exception_on_subscription_key_check( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( @@ -159,6 +159,6 @@ async def test_user_flow_exception_on_subscription_key_check( {CONF_API_KEY: SUBSCRIPTION_KEY}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 98380890e41..eb507febe8a 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -10,7 +10,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.core import HomeAssistant from homeassistant.util.decorator import Registry -from .common import async_capture_events +from .common import async_capture_events, import_and_test_deprecated_constant_enum @pytest.fixture @@ -802,3 +802,14 @@ async def test_find_flows_by_init_data_type( ) assert len(wifi_flows) == 0 assert len(manager.async_progress()) == 0 + + +@pytest.mark.parametrize(("enum"), list(data_entry_flow.FlowResultType)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: data_entry_flow.FlowResultType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1" + ) From d83dafa14a8729e36b49bc34dbc8314d225fb0d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 00:26:40 -1000 Subject: [PATCH 661/927] Add support for attribute caching to the fan platform (#106269) --- homeassistant/components/fan/__init__.py | 35 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index ec6fc1aad7e..c53be415b8e 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -6,7 +6,7 @@ from enum import IntFlag import functools as ft import logging import math -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -38,6 +38,12 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) DOMAIN = "fan" @@ -207,7 +213,18 @@ class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes fan entities.""" -class FanEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "percentage", + "speed_count", + "current_direction", + "oscillating", + "supported_features", + "preset_mode", + "preset_modes", +} + + +class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for fan entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) @@ -350,14 +367,14 @@ class FanEntity(ToggleEntity): self.percentage is not None and self.percentage > 0 ) or self.preset_mode is not None - @property + @cached_property def percentage(self) -> int | None: """Return the current speed as a percentage.""" if hasattr(self, "_attr_percentage"): return self._attr_percentage return 0 - @property + @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if hasattr(self, "_attr_speed_count"): @@ -369,12 +386,12 @@ class FanEntity(ToggleEntity): """Return the step size for percentage.""" return 100 / self.speed_count - @property + @cached_property def current_direction(self) -> str | None: """Return the current direction of the fan.""" return self._attr_current_direction - @property + @cached_property def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._attr_oscillating @@ -417,12 +434,12 @@ class FanEntity(ToggleEntity): return data - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite. @@ -432,7 +449,7 @@ class FanEntity(ToggleEntity): return self._attr_preset_mode return None - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. From da684d6a7bb1f2bb90308f46deecba87eb98d030 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 23 Dec 2023 12:20:15 +0100 Subject: [PATCH 662/927] Add diagnostics support to Tankerkoenig (#106301) --- .../components/tankerkoenig/diagnostics.py | 32 ++++++ .../snapshots/test_diagnostics.ambr | 43 ++++++++ .../tankerkoenig/test_diagnostics.py | 103 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 homeassistant/components/tankerkoenig/diagnostics.py create mode 100644 tests/components/tankerkoenig/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tankerkoenig/test_diagnostics.py diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py new file mode 100644 index 00000000000..811ec07ef19 --- /dev/null +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for Tankerkoenig.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator + +TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": coordinator.data, + } + return diag_data diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f52cb3a88a5 --- /dev/null +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8': dict({ + 'diesel': 1.659, + 'e10': 1.659, + 'e5': 1.719, + 'status': 'open', + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'fuel_types': list([ + 'e5', + ]), + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'name': 'Home', + 'radius': 2.0, + 'stations': list([ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8', + ]), + }), + 'disabled_by': None, + 'domain': 'tankerkoenig', + 'entry_id': '8036b4412f2fae6bb9dbab7fe8e37f87', + 'minor_version': 1, + 'options': dict({ + 'show_on_map': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py new file mode 100644 index 00000000000..59f273683a2 --- /dev/null +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Tests for the Tankerkoening integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.tankerkoenig.const import ( + CONF_FUEL_TYPES, + CONF_STATIONS, + DOMAIN, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_SHOW_ON_MAP, +) +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 + +MOCK_USER_DATA = { + CONF_NAME: "Home", + CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", + CONF_FUEL_TYPES: ["e5"], + CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, + CONF_RADIUS: 2.0, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + ], +} +MOCK_OPTIONS = { + CONF_SHOW_ON_MAP: True, +} + +MOCK_STATION_DATA = { + "ok": True, + "station": { + "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "name": "Station ABC", + "brand": "Station", + "street": "Somewhere Street", + "houseNumber": "1", + "postCode": "01234", + "place": "Somewhere", + "openingTimes": [], + "overrides": [], + "wholeDay": True, + "isOpen": True, + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + "lat": 51.1, + "lng": 13.1, + "state": "xxXX", + }, +} +MOCK_STATION_PRICES = { + "ok": True, + "prices": { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": { + "status": "open", + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + }, + }, +} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + with patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getStationData", + return_value=MOCK_STATION_DATA, + ), patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getPriceList", + return_value=MOCK_STATION_PRICES, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + options=MOCK_OPTIONS, + unique_id="mock.tankerkoenig", + entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot From ea7c839423c4fb546e6bd225fae2b8d3588b5942 Mon Sep 17 00:00:00 2001 From: doggkruse <6468053+doggkruse@users.noreply.github.com> Date: Sat, 23 Dec 2023 04:41:15 -0800 Subject: [PATCH 663/927] Apply units of measure provided in API when available in LaCrosse View (#106299) * Apply units of measure provided in API when available to avoid mismatch of native units. Improved fix for #106148 * Fix ruff error --- .../components/lacrosse_view/sensor.py | 24 ++++++++++++++++++- tests/components/lacrosse_view/__init__.py | 15 ++++++++++-- tests/components/lacrosse_view/test_sensor.py | 2 ++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 16da95ed598..960ab0ff325 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import logging from lacrosse_view import Sensor @@ -141,6 +141,15 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), } +# map of API returned unit of measurement strings to their corresponding unit of measurement +UNIT_OF_MEASUREMENT_MAP = { + "degrees_celsius": UnitOfTemperature.CELSIUS, + "degrees_fahrenheit": UnitOfTemperature.FAHRENHEIT, + "inches": UnitOfPrecipitationDepth.INCHES, + "millimeters": UnitOfPrecipitationDepth.MILLIMETERS, + "kilometers_per_hour": UnitOfSpeed.KILOMETERS_PER_HOUR, + "miles_per_hour": UnitOfSpeed.MILES_PER_HOUR, +} async def async_setup_entry( @@ -171,6 +180,19 @@ async def async_setup_entry( _LOGGER.warning(message) continue + + # if the API returns a different unit of measurement from the description, update it + if sensor.data.get(field) is not None: + native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( + sensor.data[field].get("unit") + ) + + if native_unit_of_measurement is not None: + description = replace( + description, + native_unit_of_measurement=native_unit_of_measurement, + ) + sensor_list.append( LaCrosseViewSensor( coordinator=coordinator, diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 66789508f05..913f6c72f24 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -70,7 +70,7 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_celsius"}}, + data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, permissions={"read": True}, model="Test", ) @@ -81,7 +81,7 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "degrees_celsius"}}, + data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, permissions={"read": True}, model="Test", ) @@ -107,3 +107,14 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( permissions={"read": True}, model="Test", ) +TEST_UNITS_OVERRIDE_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 837d13b8a4b..8fc028e2da1 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -19,6 +19,7 @@ from . import ( TEST_NO_PERMISSION_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, + TEST_UNITS_OVERRIDE_SENSOR, TEST_UNSUPPORTED_SENSOR, ) @@ -94,6 +95,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), ], ) async def test_field_types( From 043f3e640c013c295719ac1c9d5bebe735c27912 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 23 Dec 2023 22:45:06 +1000 Subject: [PATCH 664/927] Rework entity description functions in Tessie (#106287) * use lamdba to return the library function * Rename mocks * lambda number * Lambda button * Add missing * Remove context manager --- homeassistant/components/tessie/button.py | 22 +++++++++++++-------- homeassistant/components/tessie/number.py | 8 ++++---- homeassistant/components/tessie/switch.py | 24 +++++++++++------------ tests/components/tessie/common.py | 17 +--------------- tests/components/tessie/test_button.py | 8 +++++--- tests/components/tessie/test_number.py | 15 +++++++------- tests/components/tessie/test_switch.py | 14 ++++++------- 7 files changed, 51 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index fb4449f5898..5d02d9fe8aa 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -33,21 +33,27 @@ class TessieButtonEntityDescription(ButtonEntityDescription): DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( - TessieButtonEntityDescription(key="wake", func=wake, icon="mdi:sleep-off"), + TessieButtonEntityDescription(key="wake", func=lambda: wake, icon="mdi:sleep-off"), TessieButtonEntityDescription( - key="flash_lights", func=flash_lights, icon="mdi:flashlight" + key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" ), TessieButtonEntityDescription(key="honk", func=honk, icon="mdi:bullhorn"), TessieButtonEntityDescription( - key="trigger_homelink", func=trigger_homelink, icon="mdi:garage" + key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" ), TessieButtonEntityDescription( - key="enable_keyless_driving", func=enable_keyless_driving, icon="mdi:car-key" + key="enable_keyless_driving", + func=lambda: enable_keyless_driving, + icon="mdi:car-key", ), - TessieButtonEntityDescription(key="boombox", func=boombox, icon="mdi:volume-high"), - TessieButtonEntityDescription(key="frunk", func=open_front_trunk, icon="mdi:car"), TessieButtonEntityDescription( - key="trunk", func=open_close_rear_trunk, icon="mdi:car-back" + key="boombox", func=lambda: boombox, icon="mdi:volume-high" + ), + TessieButtonEntityDescription( + key="frunk", func=lambda: open_front_trunk, icon="mdi:car" + ), + TessieButtonEntityDescription( + key="trunk", func=lambda: open_close_rear_trunk, icon="mdi:car-back" ), ) @@ -81,4 +87,4 @@ class TessieButtonEntity(TessieEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.run(self.entity_description.func) + await self.run(self.entity_description.func()) diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 204260a7ab6..b7c0e145d7b 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -48,7 +48,7 @@ DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=NumberDeviceClass.CURRENT, max_key="charge_state_charge_current_request_max", - func=set_charging_amps, + func=lambda: set_charging_amps, arg="amps", ), TessieNumberEntityDescription( @@ -60,7 +60,7 @@ DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( device_class=NumberDeviceClass.BATTERY, min_key="charge_state_charge_limit_soc_min", max_key="charge_state_charge_limit_soc_max", - func=set_charge_limit, + func=lambda: set_charge_limit, arg="percent", ), TessieNumberEntityDescription( @@ -73,7 +73,7 @@ DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( mode=NumberMode.BOX, min_key="vehicle_state_speed_limit_mode_min_limit_mph", max_key="vehicle_state_speed_limit_mode_max_limit_mph", - func=set_speed_limit, + func=lambda: set_speed_limit, arg="mph", ), ) @@ -132,6 +132,6 @@ class TessieNumberEntity(TessieEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.run( - self.entity_description.func, **{self.entity_description.arg: value} + self.entity_description.func(), **{self.entity_description.arg: value} ) self.set((self.key, value)) diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 179beafe290..2dd54cf7ed1 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -43,32 +43,32 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( TessieSwitchEntityDescription( key="charge_state_charge_enable_request", - on_func=start_charging, - off_func=stop_charging, + on_func=lambda: start_charging, + off_func=lambda: stop_charging, icon="mdi:ev-station", ), TessieSwitchEntityDescription( key="climate_state_defrost_mode", - on_func=start_defrost, - off_func=stop_defrost, + on_func=lambda: start_defrost, + off_func=lambda: stop_defrost, icon="mdi:snowflake", ), TessieSwitchEntityDescription( key="vehicle_state_sentry_mode", - on_func=enable_sentry_mode, - off_func=disable_sentry_mode, + on_func=lambda: enable_sentry_mode, + off_func=lambda: disable_sentry_mode, icon="mdi:shield-car", ), TessieSwitchEntityDescription( key="vehicle_state_valet_mode", - on_func=enable_valet_mode, - off_func=disable_valet_mode, + on_func=lambda: enable_valet_mode, + off_func=lambda: disable_valet_mode, icon="mdi:car-key", ), TessieSwitchEntityDescription( key="climate_state_steering_wheel_heater", - on_func=start_steering_wheel_heater, - off_func=stop_steering_wheel_heater, + on_func=lambda: start_steering_wheel_heater, + off_func=lambda: stop_steering_wheel_heater, icon="mdi:steering", ), ) @@ -112,10 +112,10 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" - await self.run(self.entity_description.on_func) + await self.run(self.entity_description.on_func()) self.set((self.entity_description.key, True)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Switch.""" - await self.run(self.entity_description.off_func) + await self.run(self.entity_description.off_func()) self.set((self.entity_description.key, False)) diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index c0f79d26a37..a26f4becf78 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -1,8 +1,7 @@ """Tessie common helpers for tests.""" -from contextlib import contextmanager from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo @@ -10,7 +9,6 @@ from aiohttp.client import RequestInfo from homeassistant.components.tessie.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription from tests.common import MockConfigEntry, load_json_object_fixture @@ -61,16 +59,3 @@ async def setup_platform(hass: HomeAssistant, side_effect=None): await hass.async_block_till_done() return mock_entry - - -@contextmanager -def patch_description( - key: str, attr: str, descriptions: tuple[EntityDescription] -) -> AsyncMock: - """Patch a description.""" - to_patch = next(filter(lambda x: x.key == key, descriptions)) - original = to_patch.func - mock = AsyncMock() - object.__setattr__(to_patch, attr, mock) - yield mock - object.__setattr__(to_patch, attr, original) diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index 6b20dd858a7..72e458cb5d6 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -1,11 +1,11 @@ """Test the Tessie button platform.""" +from unittest.mock import patch from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.tessie.button import DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .common import patch_description, setup_platform +from .common import setup_platform async def test_buttons(hass: HomeAssistant) -> None: @@ -14,7 +14,9 @@ async def test_buttons(hass: HomeAssistant) -> None: await setup_platform(hass) # Test wake button - with patch_description("wake", "func", DESCRIPTIONS) as mock_wake: + with patch( + "homeassistant.components.tessie.button.wake", + ) as mock_wake: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index f4a407f80c4..116c9a2657d 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -1,12 +1,13 @@ """Test the Tessie number platform.""" +from unittest.mock import patch from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.components.tessie.number import DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .common import TEST_VEHICLE_STATE_ONLINE, patch_description, setup_platform +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform async def test_numbers(hass: HomeAssistant) -> None: @@ -33,8 +34,8 @@ async def test_numbers(hass: HomeAssistant) -> None: ) # Test number set value functions - with patch_description( - "charge_state_charge_current_request", "func", DESCRIPTIONS + with patch( + "homeassistant.components.tessie.number.set_charging_amps", ) as mock_set_charging_amps: await hass.services.async_call( NUMBER_DOMAIN, @@ -45,8 +46,8 @@ async def test_numbers(hass: HomeAssistant) -> None: assert hass.states.get("number.test_charge_current").state == "16.0" mock_set_charging_amps.assert_called_once() - with patch_description( - "charge_state_charge_limit_soc", "func", DESCRIPTIONS + with patch( + "homeassistant.components.tessie.number.set_charge_limit", ) as mock_set_charge_limit: await hass.services.async_call( NUMBER_DOMAIN, @@ -57,8 +58,8 @@ async def test_numbers(hass: HomeAssistant) -> None: assert hass.states.get("number.test_charge_limit").state == "80.0" mock_set_charge_limit.assert_called_once() - with patch_description( - "vehicle_state_speed_limit_mode_current_limit_mph", "func", DESCRIPTIONS + with patch( + "homeassistant.components.tessie.number.set_speed_limit", ) as mock_set_speed_limit: await hass.services.async_call( NUMBER_DOMAIN, diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index e19a7aed49e..5bc24d12e5c 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -30,9 +30,8 @@ async def test_switches(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.tessie.entity.TessieEntity.run", - return_value=True, - ) as mock_run: + "homeassistant.components.tessie.switch.start_charging", + ) as mock_start_charging: # Test Switch On await hass.services.async_call( SWITCH_DOMAIN, @@ -40,9 +39,10 @@ async def test_switches(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["switch.test_charge"]}, blocking=True, ) - mock_run.assert_called_once() - mock_run.reset_mock() - + mock_start_charging.assert_called_once() + with patch( + "homeassistant.components.tessie.switch.stop_charging", + ) as mock_stop_charging: # Test Switch Off await hass.services.async_call( SWITCH_DOMAIN, @@ -50,4 +50,4 @@ async def test_switches(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["switch.test_charge"]}, blocking=True, ) - mock_run.assert_called_once() + mock_stop_charging.assert_called_once() From 6da2f98d34516a8a39971829d7776c1a72811d0f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 23 Dec 2023 15:18:44 +0100 Subject: [PATCH 665/927] Fix mqtt valve is not resetting opening or closing state (#106240) * Fix mqtt valve is not resetting opening or closing state * Require state or position attr in JSON state update * Do not change `_attr_is_closed` if valve reports a position * Add comment, use tuple * Call _update_state --- homeassistant/components/mqtt/valve.py | 45 ++++++++--- tests/components/mqtt/test_valve.py | 107 +++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 66c73b91859..9d167f42d12 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -95,6 +95,8 @@ DEFAULTS = { CONF_STATE_CLOSED: STATE_CLOSED, } +RESET_CLOSING_OPENING = "reset_opening_closing" + def _validate_and_add_defaults(config: ConfigType) -> ConfigType: """Validate config options and set defaults.""" @@ -218,10 +220,12 @@ class MqttValve(MqttEntity, ValveEntity): @callback def _update_state(self, state: str) -> None: - """Update the valve state based on static payload.""" - self._attr_is_closed = state == STATE_CLOSED + """Update the valve state properties.""" self._attr_is_opening = state == STATE_OPENING self._attr_is_closing = state == STATE_CLOSING + if self.reports_position: + return + self._attr_is_closed = state == STATE_CLOSED @callback def _process_binary_valve_update( @@ -270,7 +274,11 @@ class MqttValve(MqttEntity, ValveEntity): msg.topic, ) else: - self._attr_current_valve_position = min(max(percentage_payload, 0), 100) + percentage_payload = min(max(percentage_payload, 0), 100) + self._attr_current_valve_position = percentage_payload + # Reset closing and opening if the valve is fully opened or fully closed + if state is None and percentage_payload in (0, 100): + state = RESET_CLOSING_OPENING position_set = True if state_payload and state is None and not position_set: _LOGGER.warning( @@ -301,10 +309,10 @@ class MqttValve(MqttEntity, ValveEntity): ) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" - payload_dict: Any = None - position_payload: Any = None - state_payload: Any = None payload = self._value_template(msg.payload) + payload_dict: Any = None + position_payload: Any = payload + state_payload: Any = payload if not payload: _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) @@ -312,12 +320,25 @@ class MqttValve(MqttEntity, ValveEntity): with suppress(*JSON_DECODE_EXCEPTIONS): payload_dict = json_loads(payload) - if isinstance(payload_dict, dict) and "position" in payload_dict: - position_payload = payload_dict["position"] - if isinstance(payload_dict, dict) and "state" in payload_dict: - state_payload = payload_dict["state"] - state_payload = payload if state_payload is None else state_payload - position_payload = payload if position_payload is None else position_payload + if isinstance(payload_dict, dict): + if self.reports_position and "position" not in payload_dict: + _LOGGER.warning( + "Missing required `position` attribute in json payload " + "on topic '%s', got: %s", + msg.topic, + payload, + ) + return + if not self.reports_position and "state" not in payload_dict: + _LOGGER.warning( + "Missing required `state` attribute in json payload " + " on topic '%s', got: %s", + msg.topic, + payload, + ) + return + position_payload = payload_dict.get("position") + state_payload = payload_dict.get("state") if self._config[CONF_REPORTS_POSITION]: self._process_position_valve_update( diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 04ae0cf50e6..e37b52f56fb 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -291,6 +291,113 @@ async def test_state_via_state_topic_through_position( assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +async def test_opening_closing_state_is_reset( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + messages = [ + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 50, "state": "opening"}', STATE_OPENING, 50), + ('{"position": 60}', STATE_OPENING, 60), + ('{"position": 100, "state": "opening"}', STATE_OPENING, 100), + ('{"position": 100, "state": null}', STATE_OPEN, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 40}', STATE_CLOSING, 40), + ('{"position": 0}', STATE_CLOSED, 0), + ('{"position": 10}', STATE_OPEN, 10), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 0, "state": "closing"}', STATE_CLOSING, 0), + ('{"position": 0}', STATE_CLOSED, 0), + ] + + for message, asserted_state, valve_position in messages: + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + ("hass_config", "message", "err_message"), + [ + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": False, + } + } + }, + '{"position": 0}', + "Missing required `state` attribute in json payload", + ), + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + }, + '{"state": "opening"}', + "Missing required `position` attribute in json payload", + ), + ], +) +async def test_invalid_state_updates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + message: str, + err_message: str, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + state = hass.states.get("valve.test") + assert err_message in caplog.text + + @pytest.mark.parametrize( "hass_config", [ From 0af850cbb673bcc3e9179d55e22070959da594b7 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 23 Dec 2023 16:08:53 +0100 Subject: [PATCH 666/927] Restructure enigma2 integration to use async (#104206) Restructure the enigma2 integration to use async --- .../components/enigma2/manifest.json | 2 +- .../components/enigma2/media_player.py | 112 ++++++++++-------- requirements_all.txt | 2 +- 3 files changed, 62 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index a70d5a05eeb..ab930ba540d 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==3.2.7"] + "requirements": ["openwebifpy==4.0.0"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 8e24caf1b08..ee788251acb 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,7 +1,8 @@ """Support for Enigma2 media players.""" from __future__ import annotations -from openwebif.api import CreateDevice +from openwebif.api import OpenWebIfDevice +from openwebif.enums import RemoteControlCodes import voluptuous as vol from homeassistant.components.media_player import ( @@ -63,10 +64,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up of an enigma2 media player.""" @@ -85,24 +86,26 @@ def setup_platform( config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - device = CreateDevice( + device = OpenWebIfDevice( host=config[CONF_HOST], port=config.get(CONF_PORT), username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), is_https=config[CONF_SSL], - prefer_picon=config.get(CONF_USE_CHANNEL_ICON), - mac_address=config.get(CONF_MAC_ADDRESS), turn_off_to_deep=config.get(CONF_DEEP_STANDBY), source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) - add_devices([Enigma2Device(config[CONF_NAME], device)], True) + async_add_entities( + [Enigma2Device(config[CONF_NAME], device, await device.get_about())] + ) class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" + _attr_has_entity_name = True + _attr_media_content_type = MediaType.TVSHOW _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET @@ -118,10 +121,11 @@ class Enigma2Device(MediaPlayerEntity): ) _attr_volume_step = 5 / 100 - def __init__(self, name, device): + def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" self._name = name - self.e2_box = device + self._device: OpenWebIfDevice = device + self._device.mac_address = about["info"]["ifaces"][0]["mac"] @property def name(self): @@ -131,108 +135,114 @@ class Enigma2Device(MediaPlayerEntity): @property def unique_id(self): """Return the unique ID for this entity.""" - return self.e2_box.mac_address + return self._device.mac_address @property def state(self) -> MediaPlayerState: """Return the state of the device.""" - if self.e2_box.is_recording_playback: - return MediaPlayerState.PLAYING - return MediaPlayerState.OFF if self.e2_box.in_standby else MediaPlayerState.ON + return ( + MediaPlayerState.OFF + if self._device.status.in_standby + else MediaPlayerState.ON + ) @property def available(self) -> bool: """Return True if the device is available.""" - return not self.e2_box.is_offline + return not self._device.is_offline - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off media player.""" - self.e2_box.turn_off() + await self._device.turn_off() - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn the media player on.""" - self.e2_box.turn_on() + await self._device.turn_on() @property def media_title(self): """Title of current playing media.""" - return self.e2_box.current_service_channel_name + return self._device.status.currservice.station @property def media_series_title(self): """Return the title of current episode of TV show.""" - return self.e2_box.current_programme_name + return self._device.status.currservice.name @property def media_channel(self): """Channel of current playing media.""" - return self.e2_box.current_service_channel_name + return self._device.status.currservice.station @property def media_content_id(self): """Service Ref of current playing media.""" - return self.e2_box.current_service_ref + return self._device.status.currservice.serviceref @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return self.e2_box.muted + return self._device.status.muted @property def media_image_url(self): """Picon url for the channel.""" - return self.e2_box.picon_url + return self._device.picon_url - def set_volume_level(self, volume: float) -> None: + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.e2_box.set_volume(int(volume * 100)) + await self._device.set_volume(int(volume * 100)) @property def volume_level(self): """Volume level of the media player (0..1).""" - return self.e2_box.volume + return ( + self._device.status.volume / 100 + if self._device.status.volume is not None + else None + ) - def media_stop(self) -> None: + async def async_media_stop(self) -> None: """Send stop command.""" - self.e2_box.set_stop() + await self._device.send_remote_control_action(RemoteControlCodes.STOP) - def media_play(self) -> None: + async def async_media_play(self) -> None: """Play media.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PLAY) - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Pause the media player.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PAUSE) - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_up() + await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP) - def media_previous_track(self) -> None: + async def async_media_previous_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_down() + self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN) - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute.""" - self.e2_box.mute_volume() + await self._device.toggle_mute() @property def source(self): """Return the current input source.""" - return self.e2_box.current_service_channel_name + return self._device.status.currservice.station @property def source_list(self): """List of available input sources.""" - return self.e2_box.source_list + return self._device.source_list - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Select input source.""" - self.e2_box.select_source(self.e2_box.sources[source]) + await self._device.zap(self._device.sources[source]) - def update(self) -> None: + async def async_update(self) -> None: """Update state of the media_player.""" - self.e2_box.update() + await self._device.update() @property def extra_state_attributes(self): @@ -243,13 +253,11 @@ class Enigma2Device(MediaPlayerEntity): currservice_begin: is in the format '21:00'. currservice_end: is in the format '21:00'. """ - if self.e2_box.in_standby: + if self._device.status.in_standby: return {} return { - ATTR_MEDIA_CURRENTLY_RECORDING: self.e2_box.status_info["isRecording"], - ATTR_MEDIA_DESCRIPTION: self.e2_box.status_info[ - "currservice_fulldescription" - ], - ATTR_MEDIA_START_TIME: self.e2_box.status_info["currservice_begin"], - ATTR_MEDIA_END_TIME: self.e2_box.status_info["currservice_end"], + ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording, + ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription, + ATTR_MEDIA_START_TIME: self._device.status.currservice.begin, + ATTR_MEDIA_END_TIME: self._device.status.currservice.end, } diff --git a/requirements_all.txt b/requirements_all.txt index 0e82fbedfc5..9ab4f780e12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==3.2.7 +openwebifpy==4.0.0 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 From d450a7f57e563c1c701e0af7bf1fa47312abf46f Mon Sep 17 00:00:00 2001 From: Marco <24938492+Marco98@users.noreply.github.com> Date: Sat, 23 Dec 2023 16:26:27 +0100 Subject: [PATCH 667/927] Improve mikrotik error handling (#106244) * improve mikrotik error handling * switch to debug * fix mock command arguments * add recommendations --------- Co-authored-by: Marco98 --- homeassistant/components/mikrotik/hub.py | 34 ++++++++++++------- tests/components/mikrotik/__init__.py | 7 +++- .../mikrotik/test_device_tracker.py | 4 ++- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index af7dfb2ab2c..d03e46a1d0b 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -91,7 +91,7 @@ class MikrotikData: def get_info(self, param: str) -> str: """Return device model name.""" cmd = IDENTITY if param == NAME else INFO - if data := self.command(MIKROTIK_SERVICES[cmd]): + if data := self.command(MIKROTIK_SERVICES[cmd], suppress_errors=(cmd == INFO)): return str(data[0].get(param)) return "" @@ -101,10 +101,18 @@ class MikrotikData: self.model = self.get_info(ATTR_MODEL) self.firmware = self.get_info(ATTR_FIRMWARE) self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) - self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) - self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) - self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) - self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) + self.support_capsman = bool( + self.command(MIKROTIK_SERVICES[IS_CAPSMAN], suppress_errors=True) + ) + self.support_wireless = bool( + self.command(MIKROTIK_SERVICES[IS_WIRELESS], suppress_errors=True) + ) + self.support_wifiwave2 = bool( + self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2], suppress_errors=True) + ) + self.support_wifi = bool( + self.command(MIKROTIK_SERVICES[IS_WIFI], suppress_errors=True) + ) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -205,7 +213,10 @@ class MikrotikData: return True def command( - self, cmd: str, params: dict[str, Any] | None = None + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) @@ -224,12 +235,11 @@ class MikrotikData: # we still have to raise CannotConnect to fail the update. raise CannotConnect from api_error except librouteros.exceptions.ProtocolError as api_error: - _LOGGER.warning( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) + emsg = "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s" + if suppress_errors and "no such command prefix" in str(api_error): + _LOGGER.debug(emsg, self._host, cmd, api_error) + return [] + _LOGGER.warning(emsg, self._host, cmd, api_error) return [] diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 158f86fe452..8e3d5eda19d 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -175,7 +175,12 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) - def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: + def mock_command( + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, + ) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 55cebaec525..bf1dc3abedf 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -52,7 +52,9 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: ) -def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: +def mock_command( + self, cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False +) -> Any: """Mock the Mikrotik command method.""" if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return True From c126022d4fe0b9a1d823177f3ca29c604a25b5e2 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Sat, 23 Dec 2023 10:39:31 -0500 Subject: [PATCH 668/927] Add switches to drop_connect integration (#106264) * Add switches to drop_connect integration * Let device update state * Simplify icon property * Use constants for icon names * Add simulated responses from devices * Use keyed form for switch value * Clean up properties --- .../components/drop_connect/__init__.py | 2 +- .../components/drop_connect/coordinator.py | 21 +- .../components/drop_connect/strings.json | 4 + .../components/drop_connect/switch.py | 124 ++++++++ tests/components/drop_connect/common.py | 6 +- tests/components/drop_connect/test_switch.py | 275 ++++++++++++++++++ 6 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/drop_connect/switch.py create mode 100644 tests/components/drop_connect/test_switch.py diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index 63aad855829..f24cc9dba3b 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -15,7 +15,7 @@ from .coordinator import DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index eb440d224d7..3f6110de9b3 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -5,11 +5,12 @@ import logging from dropmqttapi.mqttapi import DropAPI +from homeassistant.components import mqtt from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_COMMAND_TOPIC, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -23,3 +24,21 @@ class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Initialize the device.""" super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") self.drop_api = DropAPI() + + async def set_water(self, value: int): + """Change water supply state.""" + payload = self.drop_api.set_water_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) + + async def set_bypass(self, value: int): + """Change water bypass state.""" + payload = self.drop_api.set_bypass_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 2f11cf29cf8..03f16f42070 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -32,6 +32,10 @@ "reserve_in_use": { "name": "Reserve capacity in use" }, "salt": { "name": "Salt low" }, "pump": { "name": "Pump status" } + }, + "switch": { + "water": { "name": "Water supply" }, + "bypass": { "name": "Treatment bypass" } } } } diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py new file mode 100644 index 00000000000..1cd7fbf39f4 --- /dev/null +++ b/homeassistant/components/drop_connect/switch.py @@ -0,0 +1,124 @@ +"""Support for DROP switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_PROTECTION_VALVE, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +ICON_VALVE_OPEN = "mdi:valve-open" +ICON_VALVE_CLOSED = "mdi:valve-closed" +ICON_VALVE_UNKNOWN = "mdi:valve" +ICON_VALVE = {False: ICON_VALVE_CLOSED, True: ICON_VALVE_OPEN, None: ICON_VALVE_UNKNOWN} + +SWITCH_VALUE: dict[int | None, bool] = {0: False, 1: True} + +# Switch type constants +WATER_SWITCH = "water" +BYPASS_SWITCH = "bypass" + + +@dataclass(kw_only=True, frozen=True) +class DROPSwitchEntityDescription(SwitchEntityDescription): + """Describes DROP switch entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + set_fn: Callable + + +SWITCHES: list[DROPSwitchEntityDescription] = [ + DROPSwitchEntityDescription( + key=WATER_SWITCH, + translation_key=WATER_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.water(), + set_fn=lambda device, value: device.set_water(value), + ), + DROPSwitchEntityDescription( + key=BYPASS_SWITCH, + translation_key=BYPASS_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.bypass(), + set_fn=lambda device, value: device.set_bypass(value), + ), +] + +# Defines which switches are used by each device type +DEVICE_SWITCHES: dict[str, list[str]] = { + DEV_FILTER: [BYPASS_SWITCH], + DEV_HUB: [WATER_SWITCH, BYPASS_SWITCH], + DEV_PROTECTION_VALVE: [WATER_SWITCH], + DEV_SOFTENER: [BYPASS_SWITCH], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP switches from config entry.""" + _LOGGER.debug( + "Set up switch for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: + async_add_entities( + DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + for switch in SWITCHES + if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSwitch(DROPEntity, SwitchEntity): + """Representation of a DROP switch.""" + + entity_description: DROPSwitchEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return SWITCH_VALUE.get(self.entity_description.value_fn(self.coordinator)) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self.entity_description.set_fn(self.coordinator, 1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self.entity_description.set_fn(self.coordinator, 0) + + @property + def icon(self) -> str: + """Return the icon to use for dynamic states.""" + return ICON_VALVE[self.is_on] diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py index 9a07c71cb71..e7908831811 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -7,7 +7,7 @@ TEST_DATA_HUB = ( ) TEST_DATA_HUB_RESET = ( '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' - '"water":0,"bypass":0,"pMode":"AWAY","battery":0,"notif":0,"leak":0}' + '"water":0,"bypass":1,"pMode":"AWAY","battery":0,"notif":0,"leak":0}' ) TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" @@ -23,12 +23,12 @@ TEST_DATA_SOFTENER = ( '{"curFlow":5.0,"bypass":0,"battery":20,"capacity":1000,"resInUse":1,"psi":50.5}' ) TEST_DATA_SOFTENER_RESET = ( - '{"curFlow":0,"bypass":0,"battery":0,"capacity":0,"resInUse":0,"psi":null}' + '{"curFlow":0,"bypass":1,"battery":0,"capacity":0,"resInUse":0,"psi":null}' ) TEST_DATA_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/4" TEST_DATA_FILTER = '{"curFlow":19.84,"bypass":0,"battery":12,"psi":38.2}' -TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":0,"battery":0,"psi":null}' +TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":1,"battery":0,"psi":null}' TEST_DATA_PROTECTION_VALVE_TOPIC = "drop_connect/DROP-1_C0FFEE/78" TEST_DATA_PROTECTION_VALVE = ( diff --git a/tests/components/drop_connect/test_switch.py b/tests/components/drop_connect/test_switch.py new file mode 100644 index 00000000000..d7d954915c6 --- /dev/null +++ b/tests/components/drop_connect/test_switch.py @@ -0,0 +1,275 @@ +"""Test DROP switch entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_switches_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + water_supply_switch_name = "switch.hub_drop_1_c0ffee_water_supply" + hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + bypass_switch_name = "switch.hub_drop_1_c0ffee_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + +async def test_switches_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + water_supply_switch_name = "switch.protection_valve_water_supply" + hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + +async def test_switches_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + bypass_switch_name = "switch.softener_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + +async def test_switches_filter( + hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for filters.""" + config_entry_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + bypass_switch_name = "switch.filter_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF From e311a6835ebf0e4ef94941feb6a871a25d815e0e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 23 Dec 2023 16:46:25 +0100 Subject: [PATCH 669/927] Add valve platform support to google_assistant (#106139) * Add valve platform to google_assistant * Use constant for domains set --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 126 ++++++-- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 300 +++++++++++++++--- 4 files changed, 355 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 70bdc37df66..431433e2bba 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,6 +22,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, water_heater, ) @@ -65,6 +66,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "sensor", "switch", "vacuum", + "valve", "water_heater", ] @@ -95,6 +97,7 @@ TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_VALVE = f"{PREFIX_TYPES}VALVE" TYPE_WATERHEATER = f"{PREFIX_TYPES}WATERHEATER" SERVICE_REQUEST_SYNC = "request_sync" @@ -150,6 +153,7 @@ DOMAIN_TO_GOOGLE_TYPES = { sensor.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + valve.DOMAIN: TYPE_VALVE, water_heater.DOMAIN: TYPE_WATERHEATER, } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 2e861f16a02..638dfb6eff5 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -29,6 +29,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature @@ -41,6 +42,7 @@ from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -180,6 +182,57 @@ TRAITS: list[type[_Trait]] = [] FAN_SPEED_MAX_SPEED_COUNT = 5 +COVER_VALVE_STATES = { + cover.DOMAIN: { + "closed": cover.STATE_CLOSED, + "closing": cover.STATE_CLOSING, + "open": cover.STATE_OPEN, + "opening": cover.STATE_OPENING, + }, + valve.DOMAIN: { + "closed": valve.STATE_CLOSED, + "closing": valve.STATE_CLOSING, + "open": valve.STATE_OPEN, + "opening": valve.STATE_OPENING, + }, +} + +SERVICE_STOP_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_STOP_COVER, + valve.DOMAIN: valve.SERVICE_STOP_VALVE, +} +SERVICE_OPEN_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_OPEN_COVER, + valve.DOMAIN: valve.SERVICE_OPEN_VALVE, +} +SERVICE_CLOSE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_CLOSE_COVER, + valve.DOMAIN: valve.SERVICE_CLOSE_VALVE, +} +SERVICE_SET_POSITION_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_SET_COVER_POSITION, + valve.DOMAIN: valve.SERVICE_SET_VALVE_POSITION, +} + +COVER_VALVE_CURRENT_POSITION = { + cover.DOMAIN: cover.ATTR_CURRENT_POSITION, + valve.DOMAIN: valve.ATTR_CURRENT_POSITION, +} + +COVER_VALVE_POSITION = { + cover.DOMAIN: cover.ATTR_POSITION, + valve.DOMAIN: valve.ATTR_POSITION, +} + +COVER_VALVE_SET_POSITION_FEATURE = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + valve.DOMAIN: ValveEntityFeature.SET_POSITION, +} + +COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN} + +FRIENDLY_DOMAIN = {cover.DOMAIN: "Cover", valve.DOMAIN: "Valve"} + _TraitT = TypeVar("_TraitT", bound="_Trait") @@ -796,6 +849,9 @@ class StartStopTrait(_Trait): if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: return True + if domain == valve.DOMAIN and features & ValveEntityFeature.STOP: + return True + return False def sync_attributes(self): @@ -807,7 +863,7 @@ class StartStopTrait(_Trait): & VacuumEntityFeature.PAUSE != 0 } - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return {} def query_attributes(self): @@ -823,14 +879,16 @@ class StartStopTrait(_Trait): if domain == cover.DOMAIN: return {"isRunning": state in (cover.STATE_CLOSING, cover.STATE_OPENING)} + if domain == valve.DOMAIN: + return {"isRunning": True} async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" domain = self.state.domain if domain == vacuum.DOMAIN: return await self._execute_vacuum(command, data, params, challenge) - if domain == cover.DOMAIN: - return await self._execute_cover(command, data, params, challenge) + if domain in COVER_VALVE_DOMAINS: + return await self._execute_cover_or_valve(command, data, params, challenge) async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" @@ -869,28 +927,35 @@ class StartStopTrait(_Trait): context=data.context, ) - async def _execute_cover(self, command, data, params, challenge): + async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" + domain = self.state.domain if command == COMMAND_STARTSTOP: if params["start"] is False: - if self.state.state in ( - cover.STATE_CLOSING, - cover.STATE_OPENING, - ) or self.state.attributes.get(ATTR_ASSUMED_STATE): + if ( + self.state.state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + or domain == valve.DOMAIN + or self.state.attributes.get(ATTR_ASSUMED_STATE) + ): await self.hass.services.async_call( - self.state.domain, - cover.SERVICE_STOP_COVER, + domain, + SERVICE_STOP_COVER_VALVE[domain], {ATTR_ENTITY_ID: self.state.entity_id}, blocking=not self.config.should_report_state, context=data.context, ) else: raise SmartHomeError( - ERR_ALREADY_STOPPED, "Cover is already stopped" + ERR_ALREADY_STOPPED, + f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) else: raise SmartHomeError( - ERR_NOT_SUPPORTED, "Starting a cover is not supported" + ERR_NOT_SUPPORTED, f"Starting a {domain} is not supported" ) else: raise SmartHomeError( @@ -2081,7 +2146,7 @@ class OpenCloseTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return True return domain == binary_sensor.DOMAIN and device_class in ( @@ -2116,6 +2181,17 @@ class OpenCloseTrait(_Trait): and features & CoverEntityFeature.CLOSE == 0 ): response["queryOnlyOpenClose"] = True + elif ( + self.state.domain == valve.DOMAIN + and features & ValveEntityFeature.SET_POSITION == 0 + ): + response["discreteOnlyOpenClose"] = True + + if ( + features & ValveEntityFeature.OPEN == 0 + and features & ValveEntityFeature.CLOSE == 0 + ): + response["queryOnlyOpenClose"] = True if self.state.attributes.get(ATTR_ASSUMED_STATE): response["commandOnlyOpenClose"] = True @@ -2134,17 +2210,17 @@ class OpenCloseTrait(_Trait): if self.state.attributes.get(ATTR_ASSUMED_STATE): return response - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: if self.state.state == STATE_UNKNOWN: raise SmartHomeError( ERR_NOT_SUPPORTED, "Querying state is not supported" ) - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get(COVER_VALVE_CURRENT_POSITION[domain]) if position is not None: response["openPercent"] = position - elif self.state.state != cover.STATE_CLOSED: + elif self.state.state != COVER_VALVE_STATES[domain]["closed"]: response["openPercent"] = 100 else: response["openPercent"] = 0 @@ -2162,11 +2238,13 @@ class OpenCloseTrait(_Trait): domain = self.state.domain features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: svc_params = {ATTR_ENTITY_ID: self.state.entity_id} should_verify = False if command == COMMAND_OPENCLOSE_RELATIVE: - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get( + COVER_VALVE_CURRENT_POSITION[domain] + ) if position is None: raise SmartHomeError( ERR_NOT_SUPPORTED, @@ -2177,16 +2255,16 @@ class OpenCloseTrait(_Trait): position = params["openPercent"] if position == 0: - service = cover.SERVICE_CLOSE_COVER + service = SERVICE_CLOSE_COVER_VALVE[domain] should_verify = False elif position == 100: - service = cover.SERVICE_OPEN_COVER + service = SERVICE_OPEN_COVER_VALVE[domain] should_verify = True - elif features & CoverEntityFeature.SET_POSITION: - service = cover.SERVICE_SET_COVER_POSITION + elif features & COVER_VALVE_SET_POSITION_FEATURE[domain]: + service = SERVICE_SET_POSITION_COVER_VALVE[domain] if position > 0: should_verify = True - svc_params[cover.ATTR_POSITION] = position + svc_params[COVER_VALVE_POSITION[domain]] = position else: raise SmartHomeError( ERR_NOT_SUPPORTED, "No support for partial open close" @@ -2200,7 +2278,7 @@ class OpenCloseTrait(_Trait): _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( - cover.DOMAIN, + domain, service, svc_params, blocking=not self.config.should_report_state, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index e29b4d5f487..9a4ad8b3da3 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -103,6 +103,7 @@ 'sensor', 'switch', 'vacuum', + 'valve', 'water_heater', ]), 'project_id': '1234', diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 30a83e7e0c3..3be0030f63e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -28,6 +28,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature @@ -46,6 +47,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( @@ -650,6 +652,48 @@ async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} +async def test_startstop_valve(hass: HomeAssistant) -> None: + """Test startStop trait support for valve domain.""" + assert helpers.get_google_type(valve.DOMAIN, None) is not None + assert trait.StartStopTrait.supported( + valve.DOMAIN, ValveEntityFeature.STOP, None, None + ) + assert not trait.StartStopTrait.supported( + valve.DOMAIN, ValveEntityFeature.SET_POSITION, None, None + ) + + state = State( + "valve.water", + valve.STATE_CLOSED, + {ATTR_SUPPORTED_FEATURES: ValveEntityFeature.STOP}, + ) + + trt = trait.StartStopTrait( + hass, + state, + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {} + + for state_value in ( + valve.STATE_CLOSED, + valve.STATE_CLOSING, + valve.STATE_OPENING, + valve.STATE_OPEN, + ): + state.state = state_value + assert trt.query_attributes() == {"isRunning": True} + + stop_calls = async_mock_service(hass, valve.DOMAIN, valve.SERVICE_STOP_VALVE) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) + assert len(stop_calls) == 1 + assert stop_calls[0].data == {ATTR_ENTITY_ID: "valve.water"} + + with pytest.raises(SmartHomeError, match="Starting a valve is not supported"): + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + + @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) async def test_color_setting_color_light( hass: HomeAssistant, supported_color_modes @@ -2823,21 +2867,59 @@ async def test_traits_unknown_domains( caplog.clear() -async def test_openclose_cover(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "set_position_service", + "close_service", + "open_service", + "set_position_feature", + "attr_position", + "attr_current_position", + ), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + cover.ATTR_POSITION, + cover.ATTR_CURRENT_POSITION, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + valve.ATTR_POSITION, + valve.ATTR_CURRENT_POSITION, + ), + ], +) +async def test_openclose_cover_valve( + hass: HomeAssistant, + domain: str, + set_position_service: str, + close_service: str, + open_service: str, + set_position_feature: int, + attr_position: str, + attr_current_position: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_service, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + "open", { - cover.ATTR_CURRENT_POSITION: 75, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + attr_current_position: 75, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2846,34 +2928,74 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} assert trt.query_attributes() == {"openPercent": 75} - calls_set = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - calls_open = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls_set = async_mock_service(hass, domain, set_position_service) + calls_open = async_mock_service(hass, domain, open_service) + calls_close = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) await trt.execute( trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 50}, {} ) assert len(calls_set) == 1 - assert calls_set[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} + assert calls_set[0].data == { + ATTR_ENTITY_ID: f"{domain}.bla", + attr_position: 50, + } + calls_set.pop(0) assert len(calls_open) == 1 - assert calls_open[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls_open[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + calls_open.pop(0) + + assert len(calls_close) == 0 + + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) + await trt.execute( + trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 0}, {} + ) + assert len(calls_set) == 1 + assert len(calls_close) == 1 + assert calls_close[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + assert len(calls_open) == 0 -async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain with unknown state.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ("domain", "open_service", "set_position_feature", "open_feature"), + [ + ( + cover.DOMAIN, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + CoverEntityFeature.OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + ValveEntityFeature.OPEN, + ), + ], +) +async def test_openclose_cover_valve_unknown_state( + hass: HomeAssistant, + open_service: str, + domain: str, + set_position_feature: int, + open_feature: int, +) -> None: + """Test OpenClose trait support with unknown state.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None + cover.DOMAIN, set_position_feature, None, None ) # No state trt = trait.OpenCloseTrait( hass, State( - "cover.bla", + f"{domain}.bla", STATE_UNKNOWN, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN}, + {ATTR_SUPPORTED_FEATURES: open_feature}, ), BASIC_CONFIG, ) @@ -2883,30 +3005,51 @@ async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: with pytest.raises(helpers.SmartHomeError): trt.query_attributes() - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises(helpers.SmartHomeError): trt.query_attributes() -async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ("domain", "set_position_service", "set_position_feature", "state_open"), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + CoverEntityFeature.SET_POSITION, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + ValveEntityFeature.SET_POSITION, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_assumed_state( + hass: HomeAssistant, + domain: str, + set_position_service: str, + set_position_feature: int, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_feature, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2916,20 +3059,37 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: assert trt.query_attributes() == {} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls = async_mock_service(hass, domain, set_position_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 40}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 40} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla", cover.ATTR_POSITION: 40} -async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported(cover.DOMAIN, 0, None, None) +@pytest.mark.parametrize( + ("domain", "state_open"), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_query_only( + hass: HomeAssistant, + domain: str, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, 0, None, None) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, ) trt = trait.OpenCloseTrait( @@ -2945,21 +3105,57 @@ async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: assert trt.query_attributes() == {"openPercent": 100} -async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "supported_features", + "open_service", + "close_service", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + ), + ], +) +async def test_openclose_cover_valve_no_position( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + supported_features: int, + open_service: str, + close_service: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + domain, + supported_features, None, None, ) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + ATTR_SUPPORTED_FEATURES: supported_features, }, ) @@ -2972,20 +3168,20 @@ async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 100} - state.state = cover.STATE_CLOSED + state.state = state_closed assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 0} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) + calls = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises( SmartHomeError, match=r"Current position not know for relative command" From 345f7f2003a346589f36499b93787f9bdbc3159a Mon Sep 17 00:00:00 2001 From: Matt <927830+mattmattmatt@users.noreply.github.com> Date: Sat, 23 Dec 2023 07:48:36 -0800 Subject: [PATCH 670/927] Fix feedreader date comparison to allow RSS entries with identical timestamps (#104925) Change feedreader publishdate comparison --- .../components/feedreader/__init__.py | 17 +++-- tests/components/feedreader/test_init.py | 63 +++++++++++++++++++ tests/fixtures/feedreader6.xml | 27 ++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/feedreader6.xml diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index eef84996d56..04511a1a986 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -197,35 +197,40 @@ class FeedManager: ) entry.update({"feed_url": self._url}) self._hass.bus.fire(self._event_type, entry) + _LOGGER.debug("New event fired for entry %s", entry.get("link")) def _publish_new_entries(self) -> None: """Publish new entries to the event bus.""" assert self._feed is not None - new_entries = False + new_entry_count = 0 self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) if self._last_entry_timestamp: self._firstrun = False else: # Set last entry timestamp as epoch time if not available self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() + # locally cache self._last_entry_timestamp so that entries published at identical times can be processed + last_entry_timestamp = self._last_entry_timestamp for entry in self._feed.entries: if ( self._firstrun or ( "published_parsed" in entry - and entry.published_parsed > self._last_entry_timestamp + and entry.published_parsed > last_entry_timestamp ) or ( "updated_parsed" in entry - and entry.updated_parsed > self._last_entry_timestamp + and entry.updated_parsed > last_entry_timestamp ) ): self._update_and_fire_entry(entry) - new_entries = True + new_entry_count += 1 else: - _LOGGER.debug("Entry %s already processed", entry) - if not new_entries: + _LOGGER.debug("Already processed entry %s", entry.get("link")) + if new_entry_count == 0: self._log_no_entries() + else: + _LOGGER.debug("%d entries published in feed %s", new_entry_count, self._url) self._firstrun = False diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 345c37dc8f1..cd906940931 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -68,6 +68,12 @@ def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader5.xml") +@pytest.fixture(name="feed_identically_timed_events") +def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: + """Load test feed data for two events published at the exact same time.""" + return load_fixture_bytes("feedreader6.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" @@ -285,6 +291,63 @@ async def test_atom_feed(hass: HomeAssistant, events, feed_atom_event) -> None: assert events[0].data.updated_parsed.tm_min == 30 +async def test_feed_identical_timestamps( + hass: HomeAssistant, events, feed_identically_timed_events +) -> None: + """Test feed with 2 entries with identical timestamps.""" + with patch( + "feedparser.http.get", + return_value=feed_identically_timed_events, + ), patch( + "homeassistant.components.feedreader.StoredData.get_timestamp", + return_value=gmtime( + datetime.fromisoformat("1970-01-01T00:00:00.0+0000").timestamp() + ), + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert len(events) == 2 + assert events[0].data.title == "Title 1" + assert events[1].data.title == "Title 2" + assert events[0].data.link == "http://www.example.com/link/1" + assert events[1].data.link == "http://www.example.com/link/2" + assert events[0].data.id == "GUID 1" + assert events[1].data.id == "GUID 2" + assert ( + events[0].data.updated_parsed.tm_year + == events[1].data.updated_parsed.tm_year + == 2018 + ) + assert ( + events[0].data.updated_parsed.tm_mon + == events[1].data.updated_parsed.tm_mon + == 4 + ) + assert ( + events[0].data.updated_parsed.tm_mday + == events[1].data.updated_parsed.tm_mday + == 30 + ) + assert ( + events[0].data.updated_parsed.tm_hour + == events[1].data.updated_parsed.tm_hour + == 15 + ) + assert ( + events[0].data.updated_parsed.tm_min + == events[1].data.updated_parsed.tm_min + == 10 + ) + assert ( + events[0].data.updated_parsed.tm_sec + == events[1].data.updated_parsed.tm_sec + == 0 + ) + + async def test_feed_updates( hass: HomeAssistant, events, feed_one_event, feed_two_event ) -> None: diff --git a/tests/fixtures/feedreader6.xml b/tests/fixtures/feedreader6.xml new file mode 100644 index 00000000000..621c89787e8 --- /dev/null +++ b/tests/fixtures/feedreader6.xml @@ -0,0 +1,27 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +0000 + Mon, 30 Apr 2018 15:00:00 +0000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +0000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + Mon, 30 Apr 2018 15:10:00 +0000 + + + + From c629b434cdeb10da7a3793f9cd61c2c5ba6181c9 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sat, 23 Dec 2023 11:24:49 -0500 Subject: [PATCH 671/927] Add energy usage sensor to A. O. Smith integration (#105616) * Add energy usage sensor to A. O. Smith integration * Address review comments * Address review comment * Address review comment * Create device outside of the entity class * Address review comment * remove platinum --- homeassistant/components/aosmith/__init__.py | 34 ++++++++--- homeassistant/components/aosmith/const.py | 3 + .../components/aosmith/coordinator.py | 42 +++++++++++-- homeassistant/components/aosmith/entity.py | 41 ++++++++----- .../components/aosmith/manifest.json | 1 - homeassistant/components/aosmith/sensor.py | 59 ++++++++++++++----- homeassistant/components/aosmith/strings.json | 3 + .../components/aosmith/water_heater.py | 22 +++---- tests/components/aosmith/conftest.py | 10 +++- .../aosmith/fixtures/get_energy_use_data.json | 19 ++++++ .../aosmith/snapshots/test_sensor.ambr | 17 +++++- tests/components/aosmith/test_config_flow.py | 23 +++++++- tests/components/aosmith/test_init.py | 32 +++++++++- tests/components/aosmith/test_sensor.py | 35 +++++++++-- 14 files changed, 275 insertions(+), 66 deletions(-) create mode 100644 tests/components/aosmith/fixtures/get_energy_use_data.json diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index cac746e189e..b75a4ad7295 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -8,10 +8,10 @@ from py_aosmith import AOSmithAPIClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN -from .coordinator import AOSmithCoordinator +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] @@ -20,8 +20,9 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] class AOSmithData: """Data for the A. O. Smith integration.""" - coordinator: AOSmithCoordinator client: AOSmithAPIClient + status_coordinator: AOSmithStatusCoordinator + energy_coordinator: AOSmithEnergyCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -31,13 +32,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) client = AOSmithAPIClient(email, password, session) - coordinator = AOSmithCoordinator(hass, client) - # Fetch initial data so we have data when entities subscribe - await coordinator.async_config_entry_first_refresh() + status_coordinator = AOSmithStatusCoordinator(hass, client) + await status_coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + for junction_id, status_data in status_coordinator.data.items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, junction_id)}, + manufacturer="A. O. Smith", + name=status_data.get("name"), + model=status_data.get("model"), + serial_number=status_data.get("serial"), + suggested_area=status_data.get("install", {}).get("location"), + sw_version=status_data.get("data", {}).get("firmwareVersion"), + ) + + energy_coordinator = AOSmithEnergyCoordinator( + hass, client, list(status_coordinator.data) + ) + await energy_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( - coordinator=coordinator, client=client + client, + status_coordinator, + energy_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py index e79b993182e..c0c693e0dac 100644 --- a/homeassistant/components/aosmith/const.py +++ b/homeassistant/components/aosmith/const.py @@ -15,6 +15,9 @@ REGULAR_INTERVAL = timedelta(seconds=30) # Update interval to be used while a mode or setpoint change is in progress. FAST_INTERVAL = timedelta(seconds=1) +# Update interval to be used for energy usage data. +ENERGY_USAGE_INTERVAL = timedelta(minutes=10) + HOT_WATER_STATUS_MAP = { "LOW": "low", "MEDIUM": "medium", diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index bdd144569dd..7d6053cc86e 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,5 +1,4 @@ """The data update coordinator for the A. O. Smith integration.""" - import logging from typing import Any @@ -13,13 +12,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL +from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVAL _LOGGER = logging.getLogger(__name__) -class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): - """Custom data update coordinator for A. O. Smith integration.""" +class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator for device status, updating with a frequent interval.""" def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: """Initialize the coordinator.""" @@ -27,7 +26,7 @@ class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): self.client = client async def _async_update_data(self) -> dict[str, dict[str, Any]]: - """Fetch latest data from API.""" + """Fetch latest data from the device status endpoint.""" try: devices = await self.client.get_devices() except AOSmithInvalidCredentialsException as err: @@ -49,3 +48,36 @@ class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): self.update_interval = REGULAR_INTERVAL return {device.get("junctionId"): device for device in devices} + + +class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): + """Coordinator for energy usage data, updating with a slower interval.""" + + def __init__( + self, + hass: HomeAssistant, + client: AOSmithAPIClient, + junction_ids: list[str], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=ENERGY_USAGE_INTERVAL + ) + self.client = client + self.junction_ids = junction_ids + + async def _async_update_data(self) -> dict[str, float]: + """Fetch latest data from the energy usage endpoint.""" + energy_usage_by_junction_id: dict[str, float] = {} + + for junction_id in self.junction_ids: + try: + energy_usage = await self.client.get_energy_use_data(junction_id) + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + energy_usage_by_junction_id[junction_id] = energy_usage.get("lifetimeKwh") + + return energy_usage_by_junction_id diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py index 20061ca36b9..107e5d7e944 100644 --- a/homeassistant/components/aosmith/entity.py +++ b/homeassistant/components/aosmith/entity.py @@ -1,5 +1,5 @@ """The base entity for the A. O. Smith integration.""" - +from typing import TypeVar from py_aosmith import AOSmithAPIClient @@ -7,28 +7,35 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import AOSmithCoordinator +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator + +_AOSmithCoordinatorT = TypeVar( + "_AOSmithCoordinatorT", bound=AOSmithStatusCoordinator | AOSmithEnergyCoordinator +) -class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]): +class AOSmithEntity(CoordinatorEntity[_AOSmithCoordinatorT]): """Base entity for A. O. Smith.""" _attr_has_entity_name = True - def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + def __init__(self, coordinator: _AOSmithCoordinatorT, junction_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self.junction_id = junction_id self._attr_device_info = DeviceInfo( - manufacturer="A. O. Smith", - name=self.device.get("name"), - model=self.device.get("model"), - serial_number=self.device.get("serial"), - suggested_area=self.device.get("install", {}).get("location"), identifiers={(DOMAIN, junction_id)}, - sw_version=self.device.get("data", {}).get("firmwareVersion"), ) + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + +class AOSmithStatusEntity(AOSmithEntity[AOSmithStatusCoordinator]): + """Base entity for entities that use data from the status coordinator.""" + @property def device(self): """Shortcut to get the device status from the coordinator data.""" @@ -40,12 +47,16 @@ class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]): device = self.device return None if device is None else device.get("data", {}) - @property - def client(self) -> AOSmithAPIClient: - """Shortcut to get the API client.""" - return self.coordinator.client - @property def available(self) -> bool: """Return True if entity is available.""" return super().available and self.device_data.get("isOnline") is True + + +class AOSmithEnergyEntity(AOSmithEntity[AOSmithEnergyCoordinator]): + """Base entity for entities that use data from the energy coordinator.""" + + @property + def energy_usage(self) -> float | None: + """Shortcut to get the energy usage from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 2e3a459d7e2..895b03cf7fd 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["py-aosmith==1.0.1"] } diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index 78c6f32232a..b0606d2dca4 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -8,26 +8,28 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AOSmithData from .const import DOMAIN, HOT_WATER_STATUS_MAP -from .coordinator import AOSmithCoordinator -from .entity import AOSmithEntity +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .entity import AOSmithEnergyEntity, AOSmithStatusEntity @dataclass(frozen=True, kw_only=True) -class AOSmithSensorEntityDescription(SensorEntityDescription): - """Define sensor entity description class.""" +class AOSmithStatusSensorEntityDescription(SensorEntityDescription): + """Entity description class for sensors using data from the status coordinator.""" value_fn: Callable[[dict[str, Any]], str | int | None] -ENTITY_DESCRIPTIONS: tuple[AOSmithSensorEntityDescription, ...] = ( - AOSmithSensorEntityDescription( +STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( + AOSmithStatusSensorEntityDescription( key="hot_water_availability", translation_key="hot_water_availability", icon="mdi:water-thermometer", @@ -47,21 +49,26 @@ async def async_setup_entry( data: AOSmithData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - AOSmithSensorEntity(data.coordinator, description, junction_id) - for description in ENTITY_DESCRIPTIONS - for junction_id in data.coordinator.data + AOSmithStatusSensorEntity(data.status_coordinator, description, junction_id) + for description in STATUS_ENTITY_DESCRIPTIONS + for junction_id in data.status_coordinator.data + ) + + async_add_entities( + AOSmithEnergySensorEntity(data.energy_coordinator, junction_id) + for junction_id in data.status_coordinator.data ) -class AOSmithSensorEntity(AOSmithEntity, SensorEntity): - """The sensor entity for the A. O. Smith integration.""" +class AOSmithStatusSensorEntity(AOSmithStatusEntity, SensorEntity): + """Class for sensor entities that use data from the status coordinator.""" - entity_description: AOSmithSensorEntityDescription + entity_description: AOSmithStatusSensorEntityDescription def __init__( self, - coordinator: AOSmithCoordinator, - description: AOSmithSensorEntityDescription, + coordinator: AOSmithStatusCoordinator, + description: AOSmithStatusSensorEntityDescription, junction_id: str, ) -> None: """Initialize the entity.""" @@ -73,3 +80,27 @@ class AOSmithSensorEntity(AOSmithEntity, SensorEntity): def native_value(self) -> str | int | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.device) + + +class AOSmithEnergySensorEntity(AOSmithEnergyEntity, SensorEntity): + """Class for the energy sensor entity.""" + + _attr_translation_key = "energy_usage" + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 1 + + def __init__( + self, + coordinator: AOSmithEnergyCoordinator, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = f"energy_usage_{junction_id}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.energy_usage diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json index 0f1fcfc1744..0ca4e2e9094 100644 --- a/homeassistant/components/aosmith/strings.json +++ b/homeassistant/components/aosmith/strings.json @@ -34,6 +34,9 @@ "medium": "Medium", "high": "High" } + }, + "energy_usage": { + "name": "Energy usage" } } } diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index 8002373573f..8c42048d439 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -23,8 +23,8 @@ from .const import ( AOSMITH_MODE_VACATION, DOMAIN, ) -from .coordinator import AOSmithCoordinator -from .entity import AOSmithEntity +from .coordinator import AOSmithStatusCoordinator +from .entity import AOSmithStatusEntity MODE_HA_TO_AOSMITH = { STATE_OFF: AOSMITH_MODE_VACATION, @@ -54,22 +54,24 @@ async def async_setup_entry( """Set up A. O. Smith water heater platform.""" data: AOSmithData = hass.data[DOMAIN][entry.entry_id] - entities = [] - - for junction_id in data.coordinator.data: - entities.append(AOSmithWaterHeaterEntity(data.coordinator, junction_id)) - - async_add_entities(entities) + async_add_entities( + AOSmithWaterHeaterEntity(data.status_coordinator, junction_id) + for junction_id in data.status_coordinator.data + ) -class AOSmithWaterHeaterEntity(AOSmithEntity, WaterHeaterEntity): +class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): """The water heater entity for the A. O. Smith integration.""" _attr_name = None _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_min_temp = 95 - def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + def __init__( + self, + coordinator: AOSmithStatusCoordinator, + junction_id: str, + ) -> None: """Initialize the entity.""" super().__init__(coordinator, junction_id) self._attr_unique_id = junction_id diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index f0ece65d56f..61c1fc9a562 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -10,7 +10,11 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from tests.common import MockConfigEntry, load_json_array_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) FIXTURE_USER_INPUT = { CONF_EMAIL: "testemail@example.com", @@ -47,9 +51,13 @@ def get_devices_fixture() -> str: async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: """Return a mocked client.""" get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + get_energy_use_fixture = load_json_object_fixture( + "get_energy_use_data.json", DOMAIN + ) client_mock = MagicMock(AOSmithAPIClient) client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) return client_mock diff --git a/tests/components/aosmith/fixtures/get_energy_use_data.json b/tests/components/aosmith/fixtures/get_energy_use_data.json new file mode 100644 index 00000000000..989ddab5399 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_energy_use_data.json @@ -0,0 +1,19 @@ +{ + "average": 2.7552000000000003, + "graphData": [ + { + "date": "2023-10-30T04:00:00.000Z", + "kwh": 2.01 + }, + { + "date": "2023-10-31T04:00:00.000Z", + "kwh": 1.542 + }, + { + "date": "2023-11-01T04:00:00.000Z", + "kwh": 1.908 + } + ], + "lifetimeKwh": 132.825, + "startDate": "Oct 30" +} diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr index 8499a98c8e5..d4376c64a01 100644 --- a/tests/components/aosmith/snapshots/test_sensor.ambr +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -1,5 +1,20 @@ # serializer version: 1 -# name: test_state +# name: test_state[sensor.my_water_heater_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'My water heater Energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '132.825', + }) +# --- +# name: test_state[sensor.my_water_heater_hot_water_availability] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py index 5d3e986e05e..d6cf1655b14 100644 --- a/tests/components/aosmith/test_config_flow.py +++ b/tests/components/aosmith/test_config_flow.py @@ -1,4 +1,5 @@ """Test the A. O. Smith config flow.""" +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -6,7 +7,11 @@ from py_aosmith import AOSmithInvalidCredentialsException import pytest from homeassistant import config_entries -from homeassistant.components.aosmith.const import DOMAIN, REGULAR_INTERVAL +from homeassistant.components.aosmith.const import ( + DOMAIN, + ENERGY_USAGE_INTERVAL, + REGULAR_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant @@ -87,21 +92,30 @@ async def test_form_exception( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("api_method", "wait_interval"), + [ + ("get_devices", REGULAR_INTERVAL), + ("get_energy_use_data", ENERGY_USAGE_INTERVAL), + ], +) async def test_reauth_flow( freezer: FrozenDateTimeFactory, hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock, + api_method: str, + wait_interval: timedelta, ) -> None: """Test reauth works.""" entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED - mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + getattr(mock_client, api_method).side_effect = AOSmithInvalidCredentialsException( "Authentication error" ) - freezer.tick(REGULAR_INTERVAL) + freezer.tick(wait_interval) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -112,6 +126,9 @@ async def test_reauth_flow( with patch( "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", return_value=[], + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + return_value=[], ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( flows[0]["flow_id"], diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py index 8ab699e6f1c..463932e930a 100644 --- a/tests/components/aosmith/test_init.py +++ b/tests/components/aosmith/test_init.py @@ -15,7 +15,11 @@ from homeassistant.components.aosmith.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, +) async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: @@ -25,10 +29,10 @@ async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_config_entry_not_ready( +async def test_config_entry_not_ready_get_devices_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the config entry not ready.""" + """Test the config entry not ready when get_devices fails.""" mock_config_entry.add_to_hass(hass) with patch( @@ -41,6 +45,28 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_not_ready_get_energy_use_data_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config entry not ready when get_energy_use_data fails.""" + mock_config_entry.add_to_hass(hass) + + get_devices_fixture = load_json_array_fixture("get_devices.json", DOMAIN) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=get_devices_fixture, + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("get_devices_fixture", "time_to_wait", "expected_call_count"), [ diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py index 99626b09e83..f94dfdb710c 100644 --- a/tests/components/aosmith/test_sensor.py +++ b/tests/components/aosmith/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the sensor platform of the A. O. Smith integration.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -8,20 +9,42 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("entity_id", "unique_id"), + [ + ( + "sensor.my_water_heater_hot_water_availability", + "hot_water_availability_junctionId", + ), + ("sensor.my_water_heater_energy_usage", "energy_usage_junctionId"), + ], +) async def test_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, + entity_id: str, + unique_id: str, ) -> None: - """Test the setup of the sensor entity.""" - entry = entity_registry.async_get("sensor.my_water_heater_hot_water_availability") + """Test the setup of the sensor entities.""" + entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "hot_water_availability_junctionId" + assert entry.unique_id == unique_id +@pytest.mark.parametrize( + ("entity_id"), + [ + "sensor.my_water_heater_hot_water_availability", + "sensor.my_water_heater_energy_usage", + ], +) async def test_state( - hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_id: str, ) -> None: - """Test the state of the sensor entity.""" - state = hass.states.get("sensor.my_water_heater_hot_water_availability") + """Test the state of the sensor entities.""" + state = hass.states.get(entity_id) assert state == snapshot From 28dccc334d6e4e181df1059888bc4a78b68f0b3f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Sat, 23 Dec 2023 18:43:52 +0100 Subject: [PATCH 672/927] ISY994: remove reference to reload service (#106302) --- homeassistant/components/isy994/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index b39bad14d45..ec7d78edd53 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -141,7 +141,7 @@ }, "rename_node": { "name": "Rename Node on ISY", - "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after calling `isy994.reload` or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", + "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", "fields": { "name": { "name": "New Name", From 4ee961cd5154ba41fbbe986f09d2ed22aa3c4eea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 08:03:44 -1000 Subject: [PATCH 673/927] Add support for attribute caching to the cover platform (#106268) --- homeassistant/components/cover/__init__.py | 31 ++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1f365b07099..1a21908860a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import IntFlag, StrEnum import functools as ft import logging -from typing import Any, ParamSpec, TypeVar, final +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, final import voluptuous as vol @@ -42,6 +42,11 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "cover" @@ -252,7 +257,17 @@ class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): device_class: CoverDeviceClass | None = None -class CoverEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_cover_position", + "current_cover_tilt_position", + "device_class", + "is_opening", + "is_closing", + "is_closed", +} + + +class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for cover entities.""" entity_description: CoverEntityDescription @@ -267,7 +282,7 @@ class CoverEntity(Entity): _cover_is_last_toggle_direction_open = True - @property + @cached_property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -275,7 +290,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_position - @property + @cached_property def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. @@ -283,7 +298,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_tilt_position - @property + @cached_property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -345,17 +360,17 @@ class CoverEntity(Entity): return supported_features - @property + @cached_property def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" return self._attr_is_opening - @property + @cached_property def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" return self._attr_is_closing - @property + @cached_property def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" return self._attr_is_closed From 1631a52b0919451b302e8ec070206ad0ccd20b6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 08:04:11 -1000 Subject: [PATCH 674/927] Add support for attribute caching to the alarm_control_panel platform (#106265) --- .../alarm_control_panel/__init__.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 356a8a3164e..dd42c6c7072 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from functools import partial import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final import voluptuous as vol @@ -47,6 +47,11 @@ from .const import ( # noqa: F401 CodeFormat, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + # As we import constants of the cost module here, we need to add the following # functions to check for deprecated constants again # Both can be removed if no deprecated constant are in this module anymore @@ -135,7 +140,15 @@ class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=Tru """A class that describes alarm control panel entities.""" -class AlarmControlPanelEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "code_format", + "changed_by", + "code_arm_required", + "supported_features", +} + + +class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An abstract class for alarm control entities.""" entity_description: AlarmControlPanelEntityDescription @@ -146,17 +159,17 @@ class AlarmControlPanelEntity(Entity): AlarmControlPanelEntityFeature(0) ) - @property + @cached_property def code_format(self) -> CodeFormat | None: """Code format or None if no code is required.""" return self._attr_code_format - @property + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._attr_code_arm_required @@ -217,7 +230,7 @@ class AlarmControlPanelEntity(Entity): """Send arm custom bypass command.""" await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) - @property + @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" return self._attr_supported_features From 3d9fc8ed779f0d12113c402ae40d468e9c3f4364 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 08:04:30 -1000 Subject: [PATCH 675/927] Add support for attribute caching to the light platform (#106260) --- homeassistant/components/light/__init__.py | 55 +++++++++++++++------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 6643884566f..c66562a53af 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -8,7 +8,7 @@ from datetime import timedelta from enum import IntFlag, StrEnum import logging import os -from typing import Any, Self, cast, final +from typing import TYPE_CHECKING, Any, Self, cast, final import voluptuous as vol @@ -33,6 +33,11 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass import homeassistant.util.color as color_util +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DOMAIN = "light" SCAN_INTERVAL = timedelta(seconds=30) DATA_PROFILES = "light_profiles" @@ -820,7 +825,25 @@ class LightEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" -class LightEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brightness", + "color_mode", + "hs_color", + "xy_color", + "rgb_color", + "rgbw_color", + "rgbww_color", + "color_temp", + "min_mireds", + "max_mireds", + "effect_list", + "effect", + "supported_color_modes", + "supported_features", +} + + +class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for light entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -855,12 +878,12 @@ class LightEntity(ToggleEntity): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None - @property + @cached_property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" return self._attr_brightness - @property + @cached_property def color_mode(self) -> ColorMode | str | None: """Return the color mode of the light.""" return self._attr_color_mode @@ -885,22 +908,22 @@ class LightEntity(ToggleEntity): return color_mode - @property + @cached_property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" return self._attr_hs_color - @property + @cached_property def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" return self._attr_xy_color - @property + @cached_property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" return self._attr_rgb_color - @property + @cached_property def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the rgbw color value [int, int, int, int].""" return self._attr_rgbw_color @@ -911,12 +934,12 @@ class LightEntity(ToggleEntity): rgbw_color = self.rgbw_color return rgbw_color - @property + @cached_property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" return self._attr_rgbww_color - @property + @cached_property def color_temp(self) -> int | None: """Return the CT color value in mireds.""" return self._attr_color_temp @@ -928,12 +951,12 @@ class LightEntity(ToggleEntity): return color_util.color_temperature_mired_to_kelvin(self.color_temp) return self._attr_color_temp_kelvin - @property + @cached_property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._attr_min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._attr_max_mireds @@ -952,12 +975,12 @@ class LightEntity(ToggleEntity): return color_util.color_temperature_mired_to_kelvin(self.min_mireds) return self._attr_max_color_temp_kelvin - @property + @cached_property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" return self._attr_effect_list - @property + @cached_property def effect(self) -> str | None: """Return the current effect.""" return self._attr_effect @@ -1138,12 +1161,12 @@ class LightEntity(ToggleEntity): return supported_color_modes - @property + @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: """Flag supported color modes.""" return self._attr_supported_color_modes - @property + @cached_property def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features From 43757ecea549f5f0f84a68f54b66d8e013613f67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 08:05:00 -1000 Subject: [PATCH 676/927] Add support for attribute caching to the select platform (#106255) --- homeassistant/components/select/__init__.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 9c978555dd5..459083cedd4 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -30,6 +30,11 @@ from .const import ( SERVICE_SELECT_PREVIOUS, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -123,7 +128,13 @@ class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): options: list[str] | None = None -class SelectEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_option", + "options", +} + + +class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Select entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -149,7 +160,7 @@ class SelectEntity(Entity): return None return current_option - @property + @cached_property def options(self) -> list[str]: """Return a set of selectable options.""" if hasattr(self, "_attr_options"): @@ -161,7 +172,7 @@ class SelectEntity(Entity): return self.entity_description.options raise AttributeError() - @property + @cached_property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self._attr_current_option From 9234852e2ab85577a1bb0ea1749251a2131a4b63 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 23 Dec 2023 19:42:41 +0100 Subject: [PATCH 677/927] Improve typing on drop_connect switch entity description (#106314) * Improve typing on drop_connext switch entity description * Update homeassistant/components/drop_connect/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/drop_connect/switch.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/drop_connect/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index 1cd7fbf39f4..b0ebe4b5a85 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging from typing import Any @@ -42,7 +42,7 @@ class DROPSwitchEntityDescription(SwitchEntityDescription): """Describes DROP switch entity.""" value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] - set_fn: Callable + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, int], Awaitable[Any]] SWITCHES: list[DROPSwitchEntityDescription] = [ From 8c0594219feaecfeb11ac158ca6f46debea4571c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 23 Dec 2023 19:58:54 +0100 Subject: [PATCH 678/927] Add entity translations to Steamist (#96182) Co-authored-by: J. Nick Koston --- homeassistant/components/steamist/entity.py | 9 ++++----- homeassistant/components/steamist/sensor.py | 6 +++--- homeassistant/components/steamist/strings.json | 15 +++++++++++++++ homeassistant/components/steamist/switch.py | 6 ++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/steamist/entity.py b/homeassistant/components/steamist/entity.py index 94b3d32eaa4..78340dab363 100644 --- a/homeassistant/components/steamist/entity.py +++ b/homeassistant/components/steamist/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from aiosteamist import SteamistStatus from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -14,7 +14,9 @@ from .coordinator import SteamistDataUpdateCoordinator class SteamistEntity(CoordinatorEntity[SteamistDataUpdateCoordinator], Entity): - """Representation of an Steamist entity.""" + """Representation of a Steamist entity.""" + + _attr_has_entity_name = True def __init__( self, @@ -25,13 +27,10 @@ class SteamistEntity(CoordinatorEntity[SteamistDataUpdateCoordinator], Entity): """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - if coordinator.device_name: - self._attr_name = f"{coordinator.device_name} {description.name}" self._attr_unique_id = f"{entry.entry_id}_{description.key}" if entry.unique_id: # Only present if UDP broadcast works self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}, - name=entry.data[CONF_NAME], manufacturer="Steamist", model=entry.data[CONF_MODEL], configuration_url=f"http://{entry.data[CONF_HOST]}", diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index beb8eea47b9..dd51c485b4e 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -47,13 +47,13 @@ class SteamistSensorEntityDescription( SENSORS: tuple[SteamistSensorEntityDescription, ...] = ( SteamistSensorEntityDescription( key=_KEY_MINUTES_REMAIN, - name="Steam Minutes Remain", + translation_key="steam_minutes_remain", native_unit_of_measurement=UnitOfTime.MINUTES, value_fn=lambda status: status.minutes_remain, ), SteamistSensorEntityDescription( key=_KEY_TEMP, - name="Steam Temperature", + translation_key="steam_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda status: status.temp, @@ -79,7 +79,7 @@ async def async_setup_entry( class SteamistSensorEntity(SteamistEntity, SensorEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" entity_description: SteamistSensorEntityDescription diff --git a/homeassistant/components/steamist/strings.json b/homeassistant/components/steamist/strings.json index 8827df6a08a..7bc3685472a 100644 --- a/homeassistant/components/steamist/strings.json +++ b/homeassistant/components/steamist/strings.json @@ -28,5 +28,20 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "not_steamist_device": "Not a steamist device" } + }, + "entity": { + "sensor": { + "steam_minutes_remain": { + "name": "Steam minutes remain" + }, + "steam_temperature": { + "name": "Steam temperature" + } + }, + "switch": { + "steam_active": { + "name": "Steam active" + } + } } } diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index af9e894b70d..a9a7526c560 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -13,7 +13,9 @@ from .coordinator import SteamistDataUpdateCoordinator from .entity import SteamistEntity ACTIVE_SWITCH = SwitchEntityDescription( - key="active", icon="mdi:pot-steam", name="Steam Active" + key="active", + icon="mdi:pot-steam", + translation_key="steam_active", ) @@ -30,7 +32,7 @@ async def async_setup_entry( class SteamistSwitchEntity(SteamistEntity, SwitchEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" @property def is_on(self) -> bool: From 55a5e9c4b5e7fa4542b5b9b48bad1761694fbb87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 23 Dec 2023 20:04:34 +0100 Subject: [PATCH 679/927] Update psutil to 5.9.7 (#106295) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 3bcbc75d3b7..3288f4299dc 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.6"] + "requirements": ["psutil==5.9.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ab4f780e12..944cf160965 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1531,7 +1531,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.6 +psutil==5.9.7 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 From ebdf7b9c8c60e2d9b85e2995282a1336165b635f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 20:18:51 +0100 Subject: [PATCH 680/927] Deprecate some deprecated const constants (#106230) * Deprecate some deprecated const constants * Improve code * fix typing * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/sensor/__init__.py | 56 ++--- homeassistant/const.py | 217 +++++++++++++++----- homeassistant/helpers/deprecation.py | 18 +- tests/components/sensor/test_init.py | 43 ++++ tests/helpers/test_deprecation.py | 79 ++++++- tests/test_const.py | 68 ++++++ 6 files changed, 393 insertions(+), 88 deletions(-) create mode 100644 tests/test_const.py diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ff66e42f466..69a99e3ac8e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -18,36 +18,36 @@ from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_AQI, + _DEPRECATED_DEVICE_CLASS_BATTERY, + _DEPRECATED_DEVICE_CLASS_CO, + _DEPRECATED_DEVICE_CLASS_CO2, + _DEPRECATED_DEVICE_CLASS_CURRENT, + _DEPRECATED_DEVICE_CLASS_DATE, + _DEPRECATED_DEVICE_CLASS_ENERGY, + _DEPRECATED_DEVICE_CLASS_FREQUENCY, + _DEPRECATED_DEVICE_CLASS_GAS, + _DEPRECATED_DEVICE_CLASS_HUMIDITY, + _DEPRECATED_DEVICE_CLASS_ILLUMINANCE, + _DEPRECATED_DEVICE_CLASS_MONETARY, + _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE, + _DEPRECATED_DEVICE_CLASS_OZONE, + _DEPRECATED_DEVICE_CLASS_PM1, + _DEPRECATED_DEVICE_CLASS_PM10, + _DEPRECATED_DEVICE_CLASS_PM25, + _DEPRECATED_DEVICE_CLASS_POWER, + _DEPRECATED_DEVICE_CLASS_POWER_FACTOR, + _DEPRECATED_DEVICE_CLASS_PRESSURE, + _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH, + _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_TEMPERATURE, + _DEPRECATED_DEVICE_CLASS_TIMESTAMP, + _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + _DEPRECATED_DEVICE_CLASS_VOLTAGE, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_AQI, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_DATE, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_FREQUENCY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - DEVICE_CLASS_VOLTAGE, EntityCategory, UnitOfTemperature, ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 40b66b6aed3..1a5965ca713 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Final +from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 @@ -307,34 +307,147 @@ EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the SensorDeviceClass enum instead. -DEVICE_CLASS_AQI: Final = "aqi" -DEVICE_CLASS_BATTERY: Final = "battery" -DEVICE_CLASS_CO: Final = "carbon_monoxide" -DEVICE_CLASS_CO2: Final = "carbon_dioxide" -DEVICE_CLASS_CURRENT: Final = "current" -DEVICE_CLASS_DATE: Final = "date" -DEVICE_CLASS_ENERGY: Final = "energy" -DEVICE_CLASS_FREQUENCY: Final = "frequency" -DEVICE_CLASS_GAS: Final = "gas" -DEVICE_CLASS_HUMIDITY: Final = "humidity" -DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" -DEVICE_CLASS_MONETARY: Final = "monetary" -DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" -DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" -DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" -DEVICE_CLASS_OZONE: Final = "ozone" -DEVICE_CLASS_PM1: Final = "pm1" -DEVICE_CLASS_PM10: Final = "pm10" -DEVICE_CLASS_PM25: Final = "pm25" -DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" -DEVICE_CLASS_POWER: Final = "power" -DEVICE_CLASS_PRESSURE: Final = "pressure" -DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" -DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" -DEVICE_CLASS_TEMPERATURE: Final = "temperature" -DEVICE_CLASS_TIMESTAMP: Final = "timestamp" -DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" -DEVICE_CLASS_VOLTAGE: Final = "voltage" +_DEPRECATED_DEVICE_CLASS_AQI: Final = ("aqi", "SensorDeviceClass.AQI", "2025.1") +_DEPRECATED_DEVICE_CLASS_BATTERY: Final = ( + "battery", + "SensorDeviceClass.BATTERY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO: Final = ( + "carbon_monoxide", + "SensorDeviceClass.CO", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO2: Final = ( + "carbon_dioxide", + "SensorDeviceClass.CO2", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CURRENT: Final = ( + "current", + "SensorDeviceClass.CURRENT", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_DATE: Final = ("date", "SensorDeviceClass.DATE", "2025.1") +_DEPRECATED_DEVICE_CLASS_ENERGY: Final = ( + "energy", + "SensorDeviceClass.ENERGY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = ( + "frequency", + "SensorDeviceClass.FREQUENCY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_GAS: Final = ("gas", "SensorDeviceClass.GAS", "2025.1") +_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = ( + "humidity", + "SensorDeviceClass.HUMIDITY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = ( + "illuminance", + "SensorDeviceClass.ILLUMINANCE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_MONETARY: Final = ( + "monetary", + "SensorDeviceClass.MONETARY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE = ( + "nitrogen_dioxide", + "SensorDeviceClass.NITROGEN_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE = ( + "nitrogen_monoxide", + "SensorDeviceClass.NITROGEN_MONOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE = ( + "nitrous_oxide", + "SensorDeviceClass.NITROUS_OXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_OZONE: Final = ("ozone", "SensorDeviceClass.OZONE", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM1: Final = ("pm1", "SensorDeviceClass.PM1", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM10: Final = ("pm10", "SensorDeviceClass.PM10", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM25: Final = ("pm25", "SensorDeviceClass.PM25", "2025.1") +_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = ( + "power_factor", + "SensorDeviceClass.POWER_FACTOR", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_POWER: Final = ("power", "SensorDeviceClass.POWER", "2025.1") +_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = ( + "pressure", + "SensorDeviceClass.PRESSURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = ( + "signal_strength", + "SensorDeviceClass.SIGNAL_STRENGTH", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE = ( + "sulphur_dioxide", + "SensorDeviceClass.SULPHUR_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = ( + "temperature", + "SensorDeviceClass.TEMPERATURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = ( + "timestamp", + "SensorDeviceClass.TIMESTAMP", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = ( + "volatile_organic_compounds", + "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = ( + "voltage", + "SensorDeviceClass.VOLTAGE", + "2025.1", +) + + +# Can be removed if no deprecated constant are in this module anymore +def __getattr__(name: str) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_globals = globals() + if f"_DEPRECATED_{name}" not in module_globals: + raise AttributeError(f"Module {__name__} has no attribute {name!r}") + + # Avoid circular import + from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel + check_if_deprecated_constant, + ) + + return check_if_deprecated_constant(name, module_globals) + + +# Can be removed if no deprecated constant are in this module anymore +def __dir__() -> list[str]: + """Return dir() with deprecated constants.""" + # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle + module_globals = globals() + + return list(module_globals) + [ + name.removeprefix("_DEPRECATED_") + for name in module_globals + if name.startswith("_DEPRECATED_") + ] + # #### STATES #### STATE_ON: Final = "on" @@ -1168,30 +1281,6 @@ PRECISION_TENTHS: Final = 0.1 # cloud, alexa, or google_home components CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"] -# ENTITY_CATEGOR* below are deprecated as of 2021.12 -# use the EntityCategory enum instead. -ENTITY_CATEGORY_CONFIG: Final = "config" -ENTITY_CATEGORY_DIAGNOSTIC: Final = "diagnostic" -ENTITY_CATEGORIES: Final[list[str]] = [ - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -] - -# The ID of the Home Assistant Media Player Cast App -CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" -# The ID of the Home Assistant Lovelace Cast App -CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" - -# User used by Supervisor -HASSIO_USER_NAME = "Supervisor" - -SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" - -# Date/Time formats -FORMAT_DATE: Final = "%Y-%m-%d" -FORMAT_TIME: Final = "%H:%M:%S" -FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" - class EntityCategory(StrEnum): """Category of an entity. @@ -1207,3 +1296,25 @@ class EntityCategory(StrEnum): # Diagnostic: An entity exposing some configuration parameter, # or diagnostics of a device. DIAGNOSTIC = "diagnostic" + + +# ENTITY_CATEGOR* below are deprecated as of 2021.12 +# use the EntityCategory enum instead. +_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = (EntityCategory.CONFIG, "2025.1") +_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = (EntityCategory.DIAGNOSTIC, "2025.1") +ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory] + +# The ID of the Home Assistant Media Player Cast App +CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" +# The ID of the Home Assistant Lovelace Cast App +CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" + +# User used by Supervisor +HASSIO_USER_NAME = "Supervisor" + +SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" + +# Date/Time formats +FORMAT_DATE: Final = "%Y-%m-%d" +FORMAT_TIME: Final = "%H:%M:%S" +FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 6c78055e0b1..efd0363732a 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -252,6 +252,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A """ module_name = module_globals.get("__name__") logger = logging.getLogger(module_name) + value = replacement = None if (deprecated_const := module_globals.get(_PREFIX_DEPRECATED + name)) is None: raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") if isinstance(deprecated_const, DeprecatedConstant): @@ -264,9 +265,22 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) breaks_in_ha_version = deprecated_const.breaks_in_ha_version - else: + elif isinstance(deprecated_const, tuple): + # Use DeprecatedConstant and DeprecatedConstant instead, where possible + # Used to avoid import cycles. + if len(deprecated_const) == 3: + value = deprecated_const[0] + replacement = deprecated_const[1] + breaks_in_ha_version = deprecated_const[2] + elif len(deprecated_const) == 2 and isinstance(deprecated_const[0], Enum): + enum = deprecated_const[0] + value = enum.value + replacement = f"{enum.__class__.__name__}.{enum.name}" + breaks_in_ha_version = deprecated_const[1] + + if value is None or replacement is None: msg = ( - f"Value of {_PREFIX_DEPRECATED}{name!r} is an instance of {type(deprecated_const)} " + f"Value of {_PREFIX_DEPRECATED}{name} is an instance of {type(deprecated_const)} " "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 2940d76f0a6..829bb5af827 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2535,3 +2535,46 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, module, enum, "STATE_CLASS_", "2025.1" ) + + +@pytest.mark.parametrize( + ("enum"), + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], +) +def test_deprecated_constants_sensor_device_class( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index ef3be2d2ef8..8e776e98096 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,4 +1,5 @@ """Test deprecation helpers.""" +from enum import StrEnum import logging import sys from typing import Any @@ -257,6 +258,26 @@ def test_deprecated_function_called_from_custom_integration( ) in caplog.text +class TestDeprecatedConstantEnum(StrEnum): + """Test deprecated constant enum.""" + + TEST = "value" + + +def _get_value(obj: DeprecatedConstant | DeprecatedConstantEnum | tuple) -> Any: + if isinstance(obj, tuple): + if len(obj) == 2: + return obj[0].value + + return obj[0] + + if isinstance(obj, DeprecatedConstant): + return obj.value + + if isinstance(obj, DeprecatedConstantEnum): + return obj.enum.value + + @pytest.mark.parametrize( ("deprecated_constant", "extra_msg"), [ @@ -268,6 +289,30 @@ def test_deprecated_function_called_from_custom_integration( DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + ("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + (1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), ], ) @pytest.mark.parametrize( @@ -282,7 +327,7 @@ def test_deprecated_function_called_from_custom_integration( ) def test_check_if_deprecated_constant( caplog: pytest.LogCaptureFixture, - deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, extra_msg: str, module_name: str, extra_extra_msg: str, @@ -316,7 +361,7 @@ def test_check_if_deprecated_constant( ], ): value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) - assert value == deprecated_constant.value + assert value == _get_value(deprecated_constant) assert ( module_name, @@ -336,6 +381,30 @@ def test_check_if_deprecated_constant( DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + ("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + (1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), ], ) @pytest.mark.parametrize( @@ -347,7 +416,7 @@ def test_check_if_deprecated_constant( ) def test_check_if_deprecated_constant_integration_not_found( caplog: pytest.LogCaptureFixture, - deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, extra_msg: str, module_name: str, ) -> None: @@ -361,7 +430,7 @@ def test_check_if_deprecated_constant_integration_not_found( "homeassistant.helpers.frame.extract_stack", side_effect=MissingIntegrationFrame ): value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) - assert value == deprecated_constant.value + assert value == _get_value(deprecated_constant) assert ( module_name, @@ -379,7 +448,7 @@ def test_test_check_if_deprecated_constant_invalid( name = "TEST_CONSTANT" excepted_msg = ( - f"Value of _DEPRECATED_{name!r} is an instance of " + f"Value of _DEPRECATED_{name} is an instance of " "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 00000000000..b06b05b27bd --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,68 @@ +"""Test const module.""" + + +from enum import Enum + +import pytest + +from homeassistant import const +from homeassistant.components import sensor + +from tests.common import import_and_test_deprecated_constant_enum + + +def _create_tuples( + value: Enum | list[Enum], constant_prefix: str +) -> list[tuple[Enum, str]]: + result = [] + for enum in value: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(const.EntityCategory, "ENTITY_CATEGORY_") + + _create_tuples( + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], + "DEVICE_CLASS_", + ), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, const, enum, constant_prefix, "2025.1" + ) From 83e1ba338af00db6dda943c811664ff6d34f2e32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 09:22:02 -1000 Subject: [PATCH 681/927] Add support for attribute caching to the switch platform (#106258) --- homeassistant/components/switch/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 1d0654cd815..a318f763fcb 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta from enum import StrEnum from functools import partial import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -32,6 +33,11 @@ from homeassistant.loader import bind_hass from .const import DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -108,13 +114,18 @@ class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): device_class: SwitchDeviceClass | None = None -class SwitchEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for switch entities.""" entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @property + @cached_property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): From b2caf15434ccc11caf528ccbc7047b4e67725ad9 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Sat, 23 Dec 2023 14:24:52 -0600 Subject: [PATCH 682/927] New integration Midea ccm15 climate (#94824) * Initial commit * Correct settings for config flow * Use scan interval * Store proper data * Remove circular dependency * Remove circular dependency * Integration can be initialized * Fix defaults * Add setup entry * Add setup entry * Dont block forever * Poll during async_setup_entry * Remove not needed async methods * Add debug info * Parse binary data * Parse binary data * Use data to update device * Use data to update device * Add CCM15DeviceState * Use DataCoordinator * Use DataCoordinator * Use DataCoordinator * Use CoordinatorEntity * Use CoordinatorEntity * Call update API * Call update API * Call update API * Call update API * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Fix bugs * Implement swing * Support swing mode, read only * Add unit test * Swing should work * Set swing mode * Add DeviceInfo * Add error code * Add error code * Add error code * Add error code * Initial commit * Refactor * Remove comment code * Try remove circular ref * Try remove circular ref * Remove circular ref * Fix bug * Fix tests * Fix tests * Increase test coverage * Increase test coverage * Increase test coverrage * Add more unit tests * Increase coverage * Update coordinator.py * Fix ruff * Set unit of temperature * Add bounds check * Fix unit tests * Add test coverage * Use Py-ccm15 * Update tests * Upgrade dependency * Apply PR feedback * Upgrade dependency * Upgrade dependency * Upgrade dependency * Force ruff * Delete not needed consts * Fix mypy * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Robert Resch * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Fix unit tests * Move climate instance * Revert "Move climate instance" This reverts commit cc5b9916b79e805b77cc0062da67aea61e22e7c5. * Apply PR feedback * Apply PR Feedback * Remove scan internal parameter * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Robert Resch * Remove empty keys * Fix tests * Use attr fields * Try refactor * Check for multiple hosts * Check for duplicates * Fix tests * Use PRECISION_WHOLE * Use str(ac_index) * Move {self._ac_host}.{self._ac_index} to construtor * Make it fancy * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Joost Lekkerkerker * Move const to class variables * Use actual config host * Move device info to construtor * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Set name to none, dont ask for poll * Undo name change * Dont use coordinator in config flow * Dont use coordinator in config flow * Check already configured * Apply PR comments * Move above * Use device info name * Update tests/components/ccm15/test_coordinator.py Co-authored-by: Joost Lekkerkerker * Update tests/components/ccm15/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Apply feedback * Remove logger debug calls * Add new test to check for dupplicates * Test error * Use better name for test * Update homeassistant/components/ccm15/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/ccm15/config_flow.py Co-authored-by: Joost Lekkerkerker * Use prop data for all getters * Fix tests * Improve tests * Improve tests, v2 * Replace log message by comment * No need to do bounds check * Update config_flow.py * Update test_config_flow.py * Update test_coordinator.py * Update test_coordinator.py * Create test_climate.py * Delete tests/components/ccm15/test_coordinator.py * Update coordinator.py * Update __init__.py * Create test_climate.ambr * Update conftest.py * Update test_climate.py * Create test_init.py * Update .coveragerc * Update __init__.py * We need to check bounds after all * Add more test coverage * Test is not None * Use better naming * fix tests * Add available property * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Use snapshots to simulate netwrok failure or power failure * Remove not needed test * Use walrus --------- Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/ccm15/__init__.py | 34 ++ homeassistant/components/ccm15/climate.py | 160 ++++++++ homeassistant/components/ccm15/config_flow.py | 56 +++ homeassistant/components/ccm15/const.py | 26 ++ homeassistant/components/ccm15/coordinator.py | 76 ++++ homeassistant/components/ccm15/manifest.json | 9 + homeassistant/components/ccm15/strings.json | 19 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ccm15/__init__.py | 1 + tests/components/ccm15/conftest.py | 41 ++ .../ccm15/snapshots/test_climate.ambr | 351 ++++++++++++++++++ tests/components/ccm15/test_climate.py | 130 +++++++ tests/components/ccm15/test_config_flow.py | 171 +++++++++ tests/components/ccm15/test_init.py | 32 ++ 18 files changed, 1121 insertions(+) create mode 100644 homeassistant/components/ccm15/__init__.py create mode 100644 homeassistant/components/ccm15/climate.py create mode 100644 homeassistant/components/ccm15/config_flow.py create mode 100644 homeassistant/components/ccm15/const.py create mode 100644 homeassistant/components/ccm15/coordinator.py create mode 100644 homeassistant/components/ccm15/manifest.json create mode 100644 homeassistant/components/ccm15/strings.json create mode 100644 tests/components/ccm15/__init__.py create mode 100644 tests/components/ccm15/conftest.py create mode 100644 tests/components/ccm15/snapshots/test_climate.ambr create mode 100644 tests/components/ccm15/test_climate.py create mode 100644 tests/components/ccm15/test_config_flow.py create mode 100644 tests/components/ccm15/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index c5ac30ea6df..e664d89a028 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -197,6 +197,8 @@ build.json @home-assistant/supervisor /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery +/homeassistant/components/ccm15/ @ocalvo +/tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/circuit/ @braam diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py new file mode 100644 index 00000000000..ae48394c732 --- /dev/null +++ b/homeassistant/components/ccm15/__init__.py @@ -0,0 +1,34 @@ +"""The Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Midea ccm15 AC Controller from a config entry.""" + + coordinator = CCM15Coordinator( + hass, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + 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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py new file mode 100644 index 00000000000..30896d12299 --- /dev/null +++ b/homeassistant/components/ccm15/climate.py @@ -0,0 +1,160 @@ +"""Climate device for CCM15 coordinator.""" +import logging +from typing import Any + +from ccm15 import CCM15DeviceState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRECISION_WHOLE, + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN +from .coordinator import CCM15Coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all climate.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + ac_data: CCM15DeviceState = coordinator.data + entities = [ + CCM15Climate(coordinator.get_host(), ac_index, coordinator) + for ac_index in ac_data.devices + ] + async_add_entities(entities) + + +class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): + """Climate device for CCM15 coordinator.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_target_temperature_step = PRECISION_WHOLE + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.AUTO, + ] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_name = None + + def __init__( + self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator + ) -> None: + """Create a climate device managed from a coordinator.""" + super().__init__(coordinator) + self._ac_index: int = ac_index + self._attr_unique_id = f"{ac_host}.{ac_index}" + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"{ac_host}.{ac_index}"), + }, + name=f"Midea {ac_index}", + manufacturer="Midea", + model="CCM15", + ) + + @property + def data(self) -> CCM15DeviceState | None: + """Return device data.""" + return self.coordinator.get_ac_data(self._ac_index) + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (data := self.data) is not None: + return data.temperature + return None + + @property + def target_temperature(self) -> int | None: + """Return target temperature.""" + if (data := self.data) is not None: + return data.temperature_setpoint + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + if (data := self.data) is not None: + mode = data.ac_mode + return CONST_CMD_STATE_MAP[mode] + return None + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + if (data := self.data) is not None: + mode = data.fan_mode + return CONST_CMD_FAN_MAP[mode] + return None + + @property + def swing_mode(self) -> str | None: + """Return swing mode.""" + if (data := self.data) is not None: + return SWING_ON if data.is_swing_on else SWING_OFF + return None + + @property + def available(self) -> bool: + """Return the avalability of the entity.""" + return self.data is not None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + if (data := self.data) is not None: + return {"error_code": data.error_code} + return {} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + await self.coordinator.async_set_temperature(self._ac_index, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode) + + async def async_turn_off(self) -> None: + """Turn off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py new file mode 100644 index 00000000000..efde47b8d30 --- /dev/null +++ b/homeassistant/components/ccm15/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from ccm15 import CCM15Device +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=80): cv.port, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Midea ccm15 AC Controller.""" + + VERSION = 1 + + 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: + self._async_abort_entries_match(user_input) + ccm15 = CCM15Device( + user_input[CONF_HOST], user_input[CONF_PORT], DEFAULT_TIMEOUT + ) + try: + if not await ccm15.async_test_connection(): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ccm15/const.py b/homeassistant/components/ccm15/const.py new file mode 100644 index 00000000000..5e8d1b82bd8 --- /dev/null +++ b/homeassistant/components/ccm15/const.py @@ -0,0 +1,26 @@ +"""Constants for the Midea ccm15 AC Controller integration.""" + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVACMode, +) + +DOMAIN = "ccm15" +DEFAULT_TIMEOUT = 10 +DEFAULT_INTERVAL = 30 + +CONST_STATE_CMD_MAP = { + HVACMode.COOL: 0, + HVACMode.HEAT: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.OFF: 4, + HVACMode.AUTO: 5, +} +CONST_CMD_STATE_MAP = {v: k for k, v in CONST_STATE_CMD_MAP.items()} +CONST_FAN_CMD_MAP = {FAN_AUTO: 0, FAN_LOW: 2, FAN_MEDIUM: 3, FAN_HIGH: 4, FAN_OFF: 5} +CONST_CMD_FAN_MAP = {v: k for k, v in CONST_FAN_CMD_MAP.items()} diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py new file mode 100644 index 00000000000..9d8a0281706 --- /dev/null +++ b/homeassistant/components/ccm15/coordinator.py @@ -0,0 +1,76 @@ +"""Climate device for CCM15 coordinator.""" +import datetime +import logging + +from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice +import httpx + +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONST_FAN_CMD_MAP, + CONST_STATE_CMD_MAP, + DEFAULT_INTERVAL, + DEFAULT_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + + +class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): + """Class to coordinate multiple CCM15Climate devices.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=host, + update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), + ) + self._ccm15 = CCM15Device(host, port, DEFAULT_TIMEOUT) + self._host = host + + def get_host(self) -> str: + """Get the host.""" + return self._host + + async def _async_update_data(self) -> CCM15DeviceState: + """Fetch data from Rain Bird device.""" + try: + return await self._fetch_data() + except httpx.RequestError as err: # pragma: no cover + raise UpdateFailed("Error communicating with Device") from err + + async def _fetch_data(self) -> CCM15DeviceState: + """Get the current status of all AC devices.""" + return await self._ccm15.get_status_async() + + async def async_set_state(self, ac_index: int, state: str, value: int) -> None: + """Set new target states.""" + if await self._ccm15.async_set_state(ac_index, state, value): + await self.async_request_refresh() + + def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: + """Get ac data from the ac_index.""" + if ac_index < 0 or ac_index >= len(self.data.devices): + # Network latency may return an empty or incomplete array + return None + return self.data.devices[ac_index] + + async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + _LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode)) + await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode]) + + async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None: + """Set the fan mode.""" + _LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode) + await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode]) + + async def async_set_temperature(self, ac_index, temp) -> None: + """Set the target temperature mode.""" + _LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp) + await self.async_set_state(ac_index, "temp", temp) diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json new file mode 100644 index 00000000000..2d985d6148a --- /dev/null +++ b/homeassistant/components/ccm15/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ccm15", + "name": "Midea ccm15 AC Controller", + "codeowners": ["@ocalvo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ccm15", + "iot_class": "local_polling", + "requirements": ["py-ccm15==0.0.9"] +} diff --git a/homeassistant/components/ccm15/strings.json b/homeassistant/components/ccm15/strings.json new file mode 100644 index 00000000000..1ac7a25e6f8 --- /dev/null +++ b/homeassistant/components/ccm15/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eaeff88f5ed..9274593b86f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -83,6 +83,7 @@ FLOWS = { "caldav", "canary", "cast", + "ccm15", "cert_expiry", "cloudflare", "co2signal", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5c19a418853..9bd3de30b29 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -801,6 +801,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ccm15": { + "name": "Midea ccm15 AC Controller", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "cert_expiry": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 944cf160965..f802ee0cf2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1554,6 +1554,9 @@ py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5483fce899..b55ea4e71d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1197,6 +1197,9 @@ py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 diff --git a/tests/components/ccm15/__init__.py b/tests/components/ccm15/__init__.py new file mode 100644 index 00000000000..fe6be699c4d --- /dev/null +++ b/tests/components/ccm15/__init__.py @@ -0,0 +1 @@ +"""Tests for the Midea ccm15 AC Controller integration.""" diff --git a/tests/components/ccm15/conftest.py b/tests/components/ccm15/conftest.py new file mode 100644 index 00000000000..910a74fa0bc --- /dev/null +++ b/tests/components/ccm15/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the Midea ccm15 AC Controller tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState, CCM15SlaveDevice +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ccm15.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock ccm15 device.""" + ccm15_devices = { + 0: CCM15SlaveDevice(bytes.fromhex("000000b0b8001b")), + 1: CCM15SlaveDevice(bytes.fromhex("00000041c0001a")), + } + device_state = CCM15DeviceState(devices=ccm15_devices) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield + + +@pytest.fixture +def network_failure_ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock empty set of ccm15 device.""" + device_state = CCM15DeviceState(devices={}) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0d4ce32fb8b --- /dev/null +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_climate_state + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'error_code': 0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_state.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'error_code': 0, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_state.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_climate_state.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ccm15/test_climate.py b/tests/components/ccm15/test_climate.py new file mode 100644 index 00000000000..36a77aa15ab --- /dev/null +++ b/tests/components/ccm15/test_climate.py @@ -0,0 +1,130 @@ +"""Unit test for CCM15 coordinator component.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_climate_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + ccm15_device: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + # Create an instance of the CCM15DeviceState class + device_state = CCM15DeviceState(devices={}) + with patch( + "ccm15.CCM15Device.CCM15Device.get_status_async", + return_value=device_state, + ): + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot diff --git a/tests/components/ccm15/test_config_flow.py b/tests/components/ccm15/test_config_flow.py new file mode 100644 index 00000000000..9b6314228cc --- /dev/null +++ b/tests/components/ccm15/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the Midea ccm15 AC Controller config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_host( + 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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_setup_entry.mock_calls) == 0 + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=False + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_duplicate_host(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/ccm15/test_init.py b/tests/components/ccm15/test_init.py new file mode 100644 index 00000000000..b65f170a656 --- /dev/null +++ b/tests/components/ccm15/test_init.py @@ -0,0 +1,32 @@ +"""Tests for the ccm15 component.""" +from unittest.mock import AsyncMock + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload(hass: HomeAssistant, ccm15_device: AsyncMock) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED From 6d14c265b4381e7c252ce1bd18e7d7aa60ef3e67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 10:39:25 -1000 Subject: [PATCH 683/927] Ensure MQTT attributes are bound to the respective platform entity (#106316) --- homeassistant/components/mqtt/climate.py | 6 ++++-- homeassistant/components/mqtt/water_heater.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index c8696071fb4..65ffd4d17c0 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -417,8 +417,8 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None = None - _attr_target_temperature_high: float | None = None + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None _feature_preset_mode: bool = False _optimistic: bool @@ -608,6 +608,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 0ccd2dbc47d..a2cf2e511a0 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -186,6 +186,8 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _default_name = DEFAULT_NAME _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: From 9052b89d632bcf5ac76accba6df8bd0578e4307a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 11:29:33 -1000 Subject: [PATCH 684/927] Add support for attribute caching to the climate platform (#106266) --- homeassistant/components/climate/__init__.py | 79 ++++++++++++++------ 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 5047714e097..5c06b9ddace 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -108,6 +108,11 @@ from .const import ( # noqa: F401 HVACMode, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DEFAULT_MIN_TEMP = 7 DEFAULT_MAX_TEMP = 35 DEFAULT_MIN_HUMIDITY = 30 @@ -217,7 +222,33 @@ class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes climate entities.""" -class ClimateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_humidity", + "target_humidity", + "hvac_modes", + "hvac_action", + "current_temperature", + "target_temperature", + "target_temperature_step", + "target_temperature_high", + "target_temperature_low", + "preset_mode", + "preset_modes", + "is_aux_heat", + "fan_mode", + "fan_modes", + "swing_mode", + "swing_modes", + "supported_features", + "min_temp", + "max_temp", + "min_humidity", + "max_humidity", +} + + +class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for climate entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -367,17 +398,17 @@ class ClimateEntity(Entity): return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity @@ -387,32 +418,32 @@ class ClimateEntity(Entity): """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return self._attr_hvac_modes - @property + @cached_property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return self._attr_hvac_action - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" return self._attr_target_temperature_step - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach. @@ -420,7 +451,7 @@ class ClimateEntity(Entity): """ return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach. @@ -428,7 +459,7 @@ class ClimateEntity(Entity): """ return self._attr_target_temperature_low - @property + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp. @@ -436,7 +467,7 @@ class ClimateEntity(Entity): """ return self._attr_preset_mode - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. @@ -444,7 +475,7 @@ class ClimateEntity(Entity): """ return self._attr_preset_modes - @property + @cached_property def is_aux_heat(self) -> bool | None: """Return true if aux heater. @@ -452,7 +483,7 @@ class ClimateEntity(Entity): """ return self._attr_is_aux_heat - @property + @cached_property def fan_mode(self) -> str | None: """Return the fan setting. @@ -460,7 +491,7 @@ class ClimateEntity(Entity): """ return self._attr_fan_mode - @property + @cached_property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes. @@ -468,7 +499,7 @@ class ClimateEntity(Entity): """ return self._attr_fan_modes - @property + @cached_property def swing_mode(self) -> str | None: """Return the swing setting. @@ -476,7 +507,7 @@ class ClimateEntity(Entity): """ return self._attr_swing_mode - @property + @cached_property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes. @@ -581,12 +612,12 @@ class ClimateEntity(Entity): if HVACMode.OFF in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.OFF) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" return self._attr_supported_features - @property + @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" if not hasattr(self, "_attr_min_temp"): @@ -595,7 +626,7 @@ class ClimateEntity(Entity): ) return self._attr_min_temp - @property + @cached_property def max_temp(self) -> float: """Return the maximum temperature.""" if not hasattr(self, "_attr_max_temp"): @@ -604,12 +635,12 @@ class ClimateEntity(Entity): ) return self._attr_max_temp - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity From 4c912fcf1b852265534ee6b3759c1d1414cc6cfe Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 23 Dec 2023 13:57:27 -0800 Subject: [PATCH 685/927] Update test data for screenlogic (#106325) --- .../screenlogic/fixtures/data_full_chem.json | 46 +++++++++++++++---- .../fixtures/data_full_no_gpm.json | 46 +++++++++++++++---- .../fixtures/data_full_no_salt_ppm.json | 46 +++++++++++++++---- .../fixtures/data_min_entity_cleanup.json | 4 +- .../fixtures/data_min_migration.json | 4 +- .../data_missing_values_chem_chlor.json | 46 +++++++++++++++---- .../snapshots/test_diagnostics.ambr | 28 +++++++++++ 7 files changed, 182 insertions(+), 38 deletions(-) diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json index 6c9ece22fcf..8cef1e7d769 100644 --- a/tests/components/screenlogic/fixtures/data_full_chem.json +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -152,6 +154,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -681,32 +691,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -814,7 +836,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -875,6 +899,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json index 93e3040f911..521d77cdb5c 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_gpm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 738.0 Rel" + "value": "POOL: 5.2 Build 738.0 Rel", + "major": 5.2, + "minor": 738.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -585,32 +595,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 0.0, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 0, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 0 @@ -718,7 +740,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "0.000" + "value": "0.000", + "major": 0, + "minor": 0 }, "water_balance": { "flags": 0, @@ -779,6 +803,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json index d17d0e41170..c37f20f35ab 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -675,32 +685,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -808,7 +830,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -854,6 +878,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json index 40f7dbe4ad5..25a52074011 100644 --- a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json index 335c98db0ae..6796eb301c4 100644 --- a/tests/components/screenlogic/fixtures/data_min_migration.json +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json index c30ee690f8a..aa0df6e3df6 100644 --- a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -142,6 +144,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -659,32 +669,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -792,7 +814,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -844,6 +868,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 0efd10fb914..9f1cc421a99 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -23,6 +23,8 @@ 'data': dict({ 'adapter': dict({ 'firmware': dict({ + 'major': 5.2, + 'minor': 736.0, 'name': 'Protocol Adapter Firmware', 'value': 'POOL: 5.2 Build 736.0 Rel', }), @@ -454,6 +456,14 @@ 'unknown_at_offset_11': 0, }), 'controller_id': 100, + 'date_time': dict({ + 'auto_dst': dict({ + 'name': 'Automatic Daylight Saving Time', + 'value': 1, + }), + 'timestamp': 1700489169.0, + 'timestamp_host': 1700517812.0, + }), 'equipment': dict({ 'flags': 98360, 'list': list([ @@ -605,33 +615,45 @@ }), 'configuration': dict({ 'calcium_harness': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Calcium Hardness', 'unit': 'ppm', 'value': 800, }), 'cya': dict({ + 'max_setpoint': 201, + 'min_setpoint': 0, 'name': 'Cyanuric Acid', 'unit': 'ppm', 'value': 45, }), 'flags': 32, 'orp_setpoint': dict({ + 'max_setpoint': 800, + 'min_setpoint': 400, 'name': 'ORP Setpoint', 'unit': 'mV', 'value': 720, }), 'ph_setpoint': dict({ + 'max_setpoint': 7.6, + 'min_setpoint': 7.2, 'name': 'pH Setpoint', 'unit': 'pH', 'value': 7.6, }), 'probe_is_celsius': 0, 'salt_tds_ppm': dict({ + 'max_setpoint': 6500, + 'min_setpoint': 500, 'name': 'Salt/TDS', 'unit': 'ppm', 'value': 1000, }), 'total_alkalinity': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Total Alkalinity', 'unit': 'ppm', 'value': 45, @@ -689,6 +711,8 @@ }), }), 'firmware': dict({ + 'major': 1, + 'minor': 60, 'name': 'IntelliChem Firmware', 'value': '1.060', }), @@ -953,6 +977,10 @@ 'value': 0, }), }), + 'super_chlorinate': dict({ + 'name': 'Super Chlorinate', + 'value': 0, + }), }), }), 'debug': dict({ From bd6e2c54e1f4f53abeaa80b6756e73e5194cb427 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 23 Dec 2023 23:12:03 +0100 Subject: [PATCH 686/927] Use shorthand attributes in enigma2 (#106318) * enigma2: add strict typing, change property functions to _attr_* * applied changes of review * changes from review --- .strict-typing | 1 + .../components/enigma2/media_player.py | 117 +++++------------- mypy.ini | 10 ++ 3 files changed, 40 insertions(+), 88 deletions(-) diff --git a/.strict-typing b/.strict-typing index a95981c9b65..d83bd4d7adb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -124,6 +124,7 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* +homeassistant.components.enigma2.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index ee788251acb..4c0911b2462 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -123,33 +123,11 @@ class Enigma2Device(MediaPlayerEntity): def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" - self._name = name self._device: OpenWebIfDevice = device self._device.mac_address = about["info"]["ifaces"][0]["mac"] - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID for this entity.""" - return self._device.mac_address - - @property - def state(self) -> MediaPlayerState: - """Return the state of the device.""" - return ( - MediaPlayerState.OFF - if self._device.status.in_standby - else MediaPlayerState.ON - ) - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return not self._device.is_offline + self._attr_name = name + self._attr_unique_id = device.mac_address async def async_turn_off(self) -> None: """Turn off media player.""" @@ -159,49 +137,10 @@ class Enigma2Device(MediaPlayerEntity): """Turn the media player on.""" await self._device.turn_on() - @property - def media_title(self): - """Title of current playing media.""" - return self._device.status.currservice.station - - @property - def media_series_title(self): - """Return the title of current episode of TV show.""" - return self._device.status.currservice.name - - @property - def media_channel(self): - """Channel of current playing media.""" - return self._device.status.currservice.station - - @property - def media_content_id(self): - """Service Ref of current playing media.""" - return self._device.status.currservice.serviceref - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._device.status.muted - - @property - def media_image_url(self): - """Picon url for the channel.""" - return self._device.picon_url - async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._device.set_volume(int(volume * 100)) - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return ( - self._device.status.volume / 100 - if self._device.status.volume is not None - else None - ) - async def async_media_stop(self) -> None: """Send stop command.""" await self._device.send_remote_control_action(RemoteControlCodes.STOP) @@ -226,16 +165,6 @@ class Enigma2Device(MediaPlayerEntity): """Mute or unmute.""" await self._device.toggle_mute() - @property - def source(self): - """Return the current input source.""" - return self._device.status.currservice.station - - @property - def source_list(self): - """List of available input sources.""" - return self._device.source_list - async def async_select_source(self, source: str) -> None: """Select input source.""" await self._device.zap(self._device.sources[source]) @@ -243,21 +172,33 @@ class Enigma2Device(MediaPlayerEntity): async def async_update(self) -> None: """Update state of the media_player.""" await self._device.update() + self._attr_available = not self._device.is_offline - @property - def extra_state_attributes(self): - """Return device specific state attributes. + if not self._device.status.in_standby: + self._attr_extra_state_attributes = { + ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording, + ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription, + ATTR_MEDIA_START_TIME: self._device.status.currservice.begin, + ATTR_MEDIA_END_TIME: self._device.status.currservice.end, + } + else: + self._attr_extra_state_attributes = {} + + self._attr_media_title = self._device.status.currservice.station + self._attr_media_series_title = self._device.status.currservice.name + self._attr_media_channel = self._device.status.currservice.station + self._attr_is_volume_muted = self._device.status.muted + self._attr_media_content_id = self._device.status.currservice.serviceref + self._attr_media_image_url = self._device.picon_url + self._attr_source = self._device.status.currservice.station + self._attr_source_list = self._device.source_list - isRecording: Is the box currently recording. - currservice_fulldescription: Full program description. - currservice_begin: is in the format '21:00'. - currservice_end: is in the format '21:00'. - """ if self._device.status.in_standby: - return {} - return { - ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording, - ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription, - ATTR_MEDIA_START_TIME: self._device.status.currservice.begin, - ATTR_MEDIA_END_TIME: self._device.status.currservice.end, - } + self._attr_state = MediaPlayerState.OFF + else: + self._attr_state = MediaPlayerState.ON + + if (volume_level := self._device.status.volume) is not None: + self._attr_volume_level = volume_level / 100 + else: + self._attr_volume_level = None diff --git a/mypy.ini b/mypy.ini index 3aa2e5dfdbf..db175cc13f1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1001,6 +1001,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.enigma2.*] +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.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true From 2f72d4f9f0e4af8a59a78a8d128c869ee462bc93 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 23 Dec 2023 23:30:31 +0100 Subject: [PATCH 687/927] Remove unnecessary async_add_job in face processing of image_processing (#106323) --- homeassistant/components/image_processing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 0852fa85e1e..bb356c09367 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -262,7 +262,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): continue face.update({ATTR_ENTITY_ID: self.entity_id}) - self.hass.async_add_job(self.hass.bus.async_fire, EVENT_DETECT_FACE, face) + self.hass.bus.async_fire(EVENT_DETECT_FACE, face) # type: ignore[arg-type] # Update entity store self.faces = faces From abd3c54cbe5088ce299a52cae49ad0b8bd3d19e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 13:01:47 -1000 Subject: [PATCH 688/927] Add support for attribute caching to the camera platform (#106256) --- homeassistant/components/camera/__init__.py | 38 ++++++++++++++------ homeassistant/components/demo/camera.py | 9 ++++- tests/components/camera/test_init.py | 27 ++++---------- tests/components/camera/test_media_source.py | 11 +++--- tests/components/rtsp_to_webrtc/conftest.py | 3 -- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index f7ce0691efb..f7552e79468 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -12,7 +12,7 @@ from functools import partial import logging import os from random import SystemRandom -from typing import Any, Final, cast, final +from typing import TYPE_CHECKING, Any, Final, cast, final from aiohttp import hdrs, web import attr @@ -82,6 +82,11 @@ from .const import ( # noqa: F401 from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SERVICE_ENABLE_MOTION: Final = "enable_motion_detection" @@ -458,7 +463,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class Camera(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brand", + "frame_interval", + "frontend_stream_type", + "is_on", + "is_recording", + "is_streaming", + "model", + "motion_detection_enabled", + "supported_features", +} + + +class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for camera entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -501,37 +519,37 @@ class Camera(Entity): """Whether or not to use stream to generate stills.""" return False - @property + @cached_property def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" return self._attr_is_recording - @property + @cached_property def is_streaming(self) -> bool: """Return true if the device is streaming.""" return self._attr_is_streaming - @property + @cached_property def brand(self) -> str | None: """Return the camera brand.""" return self._attr_brand - @property + @cached_property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._attr_motion_detection_enabled - @property + @cached_property def model(self) -> str | None: """Return the camera model.""" return self._attr_model - @property + @cached_property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" return self._attr_frame_interval @@ -649,7 +667,7 @@ class Camera(Entity): return STATE_STREAMING return STATE_IDLE - @property + @cached_property def is_on(self) -> bool: """Return true if on.""" return self._attr_is_on diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 722693280a0..502129b5c9d 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -19,6 +19,7 @@ async def async_setup_entry( [ DemoCamera("Demo camera", "image/jpg"), DemoCamera("Demo camera png", "image/png"), + DemoCameraWithoutStream("Demo camera without stream", "image/jpg"), ] ) @@ -28,7 +29,7 @@ class DemoCamera(Camera): _attr_is_streaming = True _attr_motion_detection_enabled = False - _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_supported_features = CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM def __init__(self, name: str, content_type: str) -> None: """Initialize demo camera component.""" @@ -68,3 +69,9 @@ class DemoCamera(Camera): self._attr_is_streaming = True self._attr_is_on = True self.async_write_ha_state() + + +class DemoCameraWithoutStream(DemoCamera): + """The representation of a Demo camera without stream.""" + + _attr_supported_features = CameraEntityFeature.ON_OFF diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index ca4c0fe9a52..cb9b09a85ab 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -58,10 +58,7 @@ async def mock_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ) as mock_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_stream_source: yield mock_stream_source @@ -71,10 +68,7 @@ async def mock_hls_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=HLS_STREAM_SOURCE, - ) as mock_hls_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_hls_stream_source: yield mock_hls_stream_source @@ -934,19 +928,15 @@ async def test_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") + resp = await client.get("/api/camera_proxy/camera.demo_camera_without_stream") 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.CameraEntityFeature.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 + 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", @@ -954,9 +944,6 @@ async def test_use_stream_for_stills( ) 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.CameraEntityFeature.STREAM, - ), patch( "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", return_value=True, ): diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index bbeef35b6f3..7aa41b98efa 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -22,14 +22,14 @@ async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 # Adding stream enables HLS camera hass.config.components.add("stream") item = await media_source.async_browse_media(hass, "media-source://camera") assert item.not_shown == 0 - assert len(item.children) == 2 + assert len(item.children) == 3 assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] @@ -38,10 +38,9 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: item = await media_source.async_browse_media(hass, "media-source://camera") assert item is not None assert item.title == "Camera" - assert len(item.children) == 2 - assert item.not_shown == 0 + assert len(item.children) == 1 + assert item.not_shown == 2 assert item.children[0].media_content_type == "image/jpg" - assert item.children[1].media_content_type == "image/png" async def test_browsing_filter_web_rtc( @@ -52,7 +51,7 @@ async def test_browsing_filter_web_rtc( assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 async def test_resolving(hass: HomeAssistant, mock_camera_hls) -> None: diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py index a8ce74624f8..edb8c7c4aca 100644 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -51,9 +51,6 @@ async def mock_camera(hass) -> AsyncGenerator[None, None]: ), patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ), patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, ): yield From e43f4412fa196bbb9e65ac1db4aa12339ca8c840 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 13:20:24 -1000 Subject: [PATCH 689/927] Fix native_step in number not looking at _attr_native_step (#106327) --- homeassistant/components/number/__init__.py | 4 +-- tests/components/number/test_init.py | 40 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index c1da287879f..55b281e02e1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -306,6 +306,8 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def native_step(self) -> float | None: """Return the increment/decrement step.""" + if hasattr(self, "_attr_native_step"): + return self._attr_native_step if ( hasattr(self, "entity_description") and self.entity_description.native_step is not None @@ -321,8 +323,6 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _calculate_step(self, min_value: float, max_value: float) -> float: """Return the increment/decrement step.""" - if hasattr(self, "_attr_native_step"): - return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 601a34d4271..4de47b9b844 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -131,6 +131,31 @@ class MockNumberEntityDescr(NumberEntity): return None +class MockNumberEntityAttrWithDescription(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class sets an entity description and overrides + all the values with _attr members to ensure the _attr + members take precedence over the entity description. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + class MockDefaultNumberEntityDeprecated(NumberEntity): """Mock NumberEntity device to use in tests. @@ -277,6 +302,21 @@ async def test_attributes(hass: HomeAssistant) -> None: ATTR_STEP: 2.0, } + number_5 = MockNumberEntityAttrWithDescription() + number_5.hass = hass + assert number_5.max_value == 1000.0 + assert number_5.min_value == -1000.0 + assert number_5.step == 100.0 + assert number_5.native_step == 100.0 + assert number_5.unit_of_measurement == "native_dogs" + assert number_5.value == 500.0 + assert number_5.capability_attributes == { + ATTR_MAX: 1000.0, + ATTR_MIN: -1000.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 100.0, + } + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" From b674985b202e2f309bf511067762339eeaf12d6c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 23 Dec 2023 23:20:46 +0000 Subject: [PATCH 690/927] Add 4 more entities in V2C Trydan EVSE (#105531) * adds EVSE Lock, Timer, Dynamic Intensity, Dynamic Intensity Pause * fix casing --- homeassistant/components/v2c/strings.json | 12 +++++++ homeassistant/components/v2c/switch.py | 41 ++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index bf19fe5188e..a60b61831fd 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -52,6 +52,18 @@ "switch": { "paused": { "name": "Pause session" + }, + "locked": { + "name": "Lock EVSE" + }, + "timer": { + "name": "Charge point timer" + }, + "dynamic": { + "name": "Dynamic intensity modulation" + }, + "pause_dynamic": { + "name": "Pause dynamic control modulation" } } } diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index a876af75d86..a8b4728c66d 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -7,7 +7,13 @@ import logging from typing import Any from pytrydan import Trydan, TrydanData -from pytrydan.models.trydan import PauseState +from pytrydan.models.trydan import ( + ChargePointTimerState, + DynamicState, + LockState, + PauseDynamicState, + PauseState, +) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -44,6 +50,39 @@ TRYDAN_SWITCHES = ( turn_on_fn=lambda evse: evse.pause(), turn_off_fn=lambda evse: evse.resume(), ), + V2CSwitchEntityDescription( + key="locked", + translation_key="locked", + icon="mdi:lock", + value_fn=lambda evse_data: evse_data.locked == LockState.ENABLED, + turn_on_fn=lambda evse: evse.lock(), + turn_off_fn=lambda evse: evse.unlock(), + ), + V2CSwitchEntityDescription( + key="timer", + translation_key="timer", + icon="mdi:timer", + value_fn=lambda evse_data: evse_data.timer == ChargePointTimerState.TIMER_ON, + turn_on_fn=lambda evse: evse.timer(), + turn_off_fn=lambda evse: evse.timer_disable(), + ), + V2CSwitchEntityDescription( + key="dynamic", + translation_key="dynamic", + icon="mdi:gauge", + value_fn=lambda evse_data: evse_data.dynamic == DynamicState.ENABLED, + turn_on_fn=lambda evse: evse.dynamic(), + turn_off_fn=lambda evse: evse.dynamic_disable(), + ), + V2CSwitchEntityDescription( + key="pause_dynamic", + translation_key="pause_dynamic", + icon="mdi:pause", + value_fn=lambda evse_data: evse_data.pause_dynamic + == PauseDynamicState.NOT_MODULATING, + turn_on_fn=lambda evse: evse.pause_dynamic(), + turn_off_fn=lambda evse: evse.resume_dynamic(), + ), ) From 6e6d7a0c92c406b5106b434aba32199180f816e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 13:23:32 -1000 Subject: [PATCH 691/927] Add support for attribute caching to the event platform (#106330) --- homeassistant/components/event/__init__.py | 20 ++++++++++++++++---- tests/components/event/test_init.py | 3 +++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 40e55472d12..b05c3a6f3a5 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -5,7 +5,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,6 +21,12 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -101,7 +107,13 @@ class EventExtraStoredData(ExtraStoredData): return None -class EventEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "event_types", +} + + +class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of an Event entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) @@ -115,7 +127,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @property + @cached_property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -124,7 +136,7 @@ class EventEntity(RestoreEntity): return self.entity_description.device_class return None - @property + @cached_property def event_types(self) -> list[str]: """Return a list of possible events.""" if hasattr(self, "_attr_event_types"): diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 66cda6a088a..b8ba5fb6a18 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -56,6 +56,9 @@ async def test_event() -> None: event_types=["short_press", "long_press"], device_class=EventDeviceClass.DOORBELL, ) + # Delete the cache since we changed the entity description + # at run time + del event.device_class assert event.event_types == ["short_press", "long_press"] assert event.device_class == EventDeviceClass.DOORBELL From b7579840310b389211ef2596f079944f12b00aa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 13:30:33 -1000 Subject: [PATCH 692/927] Optimize use_device_name check in base entity class (#106209) --- homeassistant/helpers/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 95d003cd11c..bf30943fb58 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -958,7 +958,7 @@ class Entity( return name device_name = device_entry.name_by_user or device_entry.name - if self.use_device_name: + if name is None and self.use_device_name: return device_name return f"{device_name} {name}" if device_name else name From f097e2a2f625698416532d46795eff796314ecd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 13:33:11 -1000 Subject: [PATCH 693/927] Add support for attribute caching to the media_player platform (#106257) --- homeassistant/components/demo/media_player.py | 20 ++++ .../components/media_player/__init__.py | 109 ++++++++++++------ tests/components/emulated_hue/test_hue_api.py | 2 + tests/components/google_assistant/__init__.py | 20 ++++ tests/components/media_player/test_init.py | 30 ++--- 5 files changed, 126 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 35bd35a2245..b0b2e1a95f5 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -38,6 +38,8 @@ async def async_setup_entry( DemoMusicPlayer(), DemoMusicPlayer("Kitchen"), DemoTVShowPlayer(), + DemoBrowsePlayer("Browse"), + DemoGroupPlayer("Group"), ] ) @@ -90,6 +92,8 @@ NETFLIX_PLAYER_SUPPORT = ( | MediaPlayerEntityFeature.STOP ) +BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -379,3 +383,19 @@ class DemoTVShowPlayer(AbstractDemoPlayer): """Set the input source.""" self._attr_source = source self.schedule_update_ha_state() + + +class DemoBrowsePlayer(AbstractDemoPlayer): + """A Demo media player that supports browse.""" + + _attr_supported_features = BROWSE_PLAYER_SUPPORT + + +class DemoGroupPlayer(AbstractDemoPlayer): + """A Demo media player that supports grouping.""" + + _attr_supported_features = ( + YOUTUBE_PLAYER_SUPPORT + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.TURN_OFF + ) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a45127d7b86..706539664ec 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -12,7 +12,7 @@ import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse from aiohttp import web @@ -131,6 +131,11 @@ from .const import ( # noqa: F401 ) from .errors import BrowseError +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -455,7 +460,43 @@ class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): volume_step: float | None = None -class MediaPlayerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "state", + "volume_level", + "volume_step", + "is_volume_muted", + "media_content_id", + "media_content_type", + "media_duration", + "media_position", + "media_position_updated_at", + "media_image_url", + "media_image_remotely_accessible", + "media_title", + "media_artist", + "media_album_name", + "media_album_artist", + "media_track", + "media_series_title", + "media_season", + "media_episode", + "media_channel", + "media_playlist", + "app_id", + "app_name", + "source", + "source_list", + "sound_mode", + "sound_mode_list", + "shuffle", + "repeat", + "group_members", + "supported_features", +} + + +class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for media player entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -507,7 +548,7 @@ class MediaPlayerEntity(Entity): _attr_volume_step: float # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -516,7 +557,7 @@ class MediaPlayerEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def state(self) -> MediaPlayerState | None: """State of the player.""" return self._attr_state @@ -528,12 +569,12 @@ class MediaPlayerEntity(Entity): self._access_token = secrets.token_hex(32) return self._access_token - @property + @cached_property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self._attr_volume_level - @property + @cached_property def volume_step(self) -> float: """Return the step to be used by the volume_up and volume_down services.""" if hasattr(self, "_attr_volume_step"): @@ -545,32 +586,32 @@ class MediaPlayerEntity(Entity): return volume_step return 0.1 - @property + @cached_property def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" return self._attr_is_volume_muted - @property + @cached_property def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._attr_media_content_id - @property + @cached_property def media_content_type(self) -> MediaType | str | None: """Content type of current playing media.""" return self._attr_media_content_type - @property + @cached_property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._attr_media_duration - @property + @cached_property def media_position(self) -> int | None: """Position of current playing media in seconds.""" return self._attr_media_position - @property + @cached_property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid. @@ -578,12 +619,12 @@ class MediaPlayerEntity(Entity): """ return self._attr_media_position_updated_at - @property + @cached_property def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._attr_media_image_url - @property + @cached_property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return self._attr_media_image_remotely_accessible @@ -618,102 +659,102 @@ class MediaPlayerEntity(Entity): """ return None, None - @property + @cached_property def media_title(self) -> str | None: """Title of current playing media.""" return self._attr_media_title - @property + @cached_property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._attr_media_artist - @property + @cached_property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._attr_media_album_name - @property + @cached_property def media_album_artist(self) -> str | None: """Album artist of current playing media, music track only.""" return self._attr_media_album_artist - @property + @cached_property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" return self._attr_media_track - @property + @cached_property def media_series_title(self) -> str | None: """Title of series of current playing media, TV show only.""" return self._attr_media_series_title - @property + @cached_property def media_season(self) -> str | None: """Season of current playing media, TV show only.""" return self._attr_media_season - @property + @cached_property def media_episode(self) -> str | None: """Episode of current playing media, TV show only.""" return self._attr_media_episode - @property + @cached_property def media_channel(self) -> str | None: """Channel currently playing.""" return self._attr_media_channel - @property + @cached_property def media_playlist(self) -> str | None: """Title of Playlist currently playing.""" return self._attr_media_playlist - @property + @cached_property def app_id(self) -> str | None: """ID of the current running app.""" return self._attr_app_id - @property + @cached_property def app_name(self) -> str | None: """Name of the current running app.""" return self._attr_app_name - @property + @cached_property def source(self) -> str | None: """Name of the current input source.""" return self._attr_source - @property + @cached_property def source_list(self) -> list[str] | None: """List of available input sources.""" return self._attr_source_list - @property + @cached_property def sound_mode(self) -> str | None: """Name of the current sound mode.""" return self._attr_sound_mode - @property + @cached_property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" return self._attr_sound_mode_list - @property + @cached_property def shuffle(self) -> bool | None: """Boolean if shuffle is enabled.""" return self._attr_shuffle - @property + @cached_property def repeat(self) -> RepeatMode | str | None: """Return current repeat mode.""" return self._attr_repeat - @property + @cached_property def group_members(self) -> list[str] | None: """List of members which are currently grouped together.""" return self._attr_group_members - @property + @cached_property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 98f99349cac..3febc42730b 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -95,6 +95,8 @@ ENTITY_IDS_BY_NUMBER = { "24": "media_player.kitchen", "25": "light.office_rgbw_lights", "26": "light.living_room_rgbww_lights", + "27": "media_player.group", + "28": "media_player.browse", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 2122818bbb4..6fc1c9f580d 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -237,6 +237,26 @@ DEMO_DEVICES = [ "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.browse", + "name": {"name": "Browse"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, + { + "id": "media_player.group", + "name": {"name": "Group"}, + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.Volume", + "action.devices.traits.Modes", + "action.devices.traits.TransportControl", + "action.devices.traits.MediaState", + ], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index b7bf35ab2f8..377cdd32748 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,7 +10,6 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerEnqueue, - MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF @@ -159,9 +158,6 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, @@ -176,7 +172,7 @@ async def test_media_browse( { "id": 5, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", "media_content_type": "album", "media_content_id": "abcd", } @@ -202,9 +198,6 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value={"bla": "yo"}, ): @@ -212,7 +205,7 @@ async def test_media_browse( { "id": 6, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", } ) @@ -231,19 +224,14 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Fake group support for DemoYoutubePlayer - with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF, - ): - await hass.services.async_call( - "media_player", - "turn_off", - {ATTR_ENTITY_ID: "media_player.bedroom"}, - blocking=True, - ) + await hass.services.async_call( + "media_player", + "turn_off", + {ATTR_ENTITY_ID: "media_player.group"}, + blocking=True, + ) - state = hass.states.get("media_player.bedroom") + state = hass.states.get("media_player.group") assert state.state == STATE_OFF assert "group_members" in state.attributes From 85e9bc6f5af619c2669a69bf974d36bc6e55cca0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 14:27:14 -1000 Subject: [PATCH 694/927] Add support for attribute caching to the image platform (#106333) --- homeassistant/components/image/__init__.py | 25 ++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index d90295f6279..4c5a9df8810 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging from random import SystemRandom -from typing import Final, final +from typing import TYPE_CHECKING, Final, final from aiohttp import hdrs, web import httpx @@ -30,6 +30,12 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -122,7 +128,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class ImageEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "content_type", + "image_last_updated", + "image_url", +} + + +class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for image entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -143,7 +156,7 @@ class ImageEntity(Entity): self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() - @property + @cached_property def content_type(self) -> str: """Image content type.""" return self._attr_content_type @@ -155,12 +168,12 @@ class ImageEntity(Entity): return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) - @property + @cached_property def image_last_updated(self) -> datetime | None: - """The time when the image was last updated.""" + """Time the image was last updated.""" return self._attr_image_last_updated - @property + @cached_property def image_url(self) -> str | None | UndefinedType: """Return URL of image.""" return self._attr_image_url From 68974a849f7ce59209ab69ef8e14b44b0af1d27c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 14:27:38 -1000 Subject: [PATCH 695/927] Add support for attribute caching to the siren platform (#106337) --- homeassistant/components/siren/__init__.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 37bab7a995d..263c6697df6 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from functools import partial import logging -from typing import Any, TypedDict, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final import voluptuous as vol @@ -38,6 +38,11 @@ from .const import ( # noqa: F401 SirenEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) @@ -165,7 +170,13 @@ class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): available_tones: list[int | str] | dict[int, str] | None = None -class SirenEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "available_tones", + "supported_features", +} + + +class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a siren device.""" _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) @@ -186,7 +197,7 @@ class SirenEntity(ToggleEntity): return None - @property + @cached_property def available_tones(self) -> list[int | str] | dict[int, str] | None: """Return a list of available tones. @@ -198,7 +209,7 @@ class SirenEntity(ToggleEntity): return self.entity_description.available_tones return None - @property + @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" return self._attr_supported_features From 63f3c2396876d301e7fbdfdf4020603535b3fa02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 14:28:08 -1000 Subject: [PATCH 696/927] Add support for attribute caching to the weather platform (#106334) --- homeassistant/components/weather/__init__.py | 67 ++++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 993c5e9503b..fa832ca8c32 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -9,6 +9,7 @@ from datetime import timedelta from functools import partial import logging from typing import ( + TYPE_CHECKING, Any, Final, Generic, @@ -84,6 +85,12 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -272,7 +279,29 @@ class PostInit(metaclass=PostInitMeta): """Finish initializing.""" -class WeatherEntity(Entity, PostInit): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_apparent_temperature", + "native_temperature", + "native_temperature_unit", + "native_dew_point", + "native_pressure", + "native_pressure_unit", + "humidity", + "native_wind_gust_speed", + "native_wind_speed", + "native_wind_speed_unit", + "wind_bearing", + "ozone", + "cloud_coverage", + "uv_index", + "native_visibility", + "native_visibility_unit", + "native_precipitation_unit", + "condition", +} + + +class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for weather data.""" _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) @@ -398,22 +427,22 @@ class WeatherEntity(Entity, PostInit): return self.async_registry_entry_updated() - @property + @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" return self._attr_native_temperature - @property + @cached_property def native_temperature(self) -> float | None: """Return the temperature in native units.""" return self._attr_native_temperature - @property + @cached_property def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" return self._attr_native_temperature_unit - @property + @cached_property def native_dew_point(self) -> float | None: """Return the dew point temperature in native units.""" return self._attr_native_dew_point @@ -441,12 +470,12 @@ class WeatherEntity(Entity, PostInit): return self._default_temperature_unit - @property + @cached_property def native_pressure(self) -> float | None: """Return the pressure in native units.""" return self._attr_native_pressure - @property + @cached_property def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" return self._attr_native_pressure_unit @@ -476,22 +505,22 @@ class WeatherEntity(Entity, PostInit): return self._default_pressure_unit - @property + @cached_property def humidity(self) -> float | None: """Return the humidity in native units.""" return self._attr_humidity - @property + @cached_property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed in native units.""" return self._attr_native_wind_gust_speed - @property + @cached_property def native_wind_speed(self) -> float | None: """Return the wind speed in native units.""" return self._attr_native_wind_speed - @property + @cached_property def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" return self._attr_native_wind_speed_unit @@ -521,32 +550,32 @@ class WeatherEntity(Entity, PostInit): return self._default_wind_speed_unit - @property + @cached_property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._attr_wind_bearing - @property + @cached_property def ozone(self) -> float | None: """Return the ozone level.""" return self._attr_ozone - @property + @cached_property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" return self._attr_cloud_coverage - @property + @cached_property def uv_index(self) -> float | None: """Return the UV index.""" return self._attr_uv_index - @property + @cached_property def native_visibility(self) -> float | None: """Return the visibility in native units.""" return self._attr_native_visibility - @property + @cached_property def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" return self._attr_native_visibility_unit @@ -603,7 +632,7 @@ class WeatherEntity(Entity, PostInit): """Return the hourly forecast in native units.""" raise NotImplementedError - @property + @cached_property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" return self._attr_native_precipitation_unit @@ -970,7 +999,7 @@ class WeatherEntity(Entity, PostInit): """Return the current state.""" return self.condition - @property + @cached_property def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition From 38e79bbf9dd3ba185fd986ce8f4b8820d8ae34b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 14:28:25 -1000 Subject: [PATCH 697/927] Add support for attribute caching to the lawn_mower platform (#106335) --- .../components/lawn_mower/__init__.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index b25f9ab34af..b1eac0a6609 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -24,6 +24,12 @@ from .const import ( LawnMowerEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -68,7 +74,13 @@ class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True) """A class that describes lawn mower entities.""" -class LawnMowerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "activity", + "supported_features", +} + + +class LawnMowerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lawn mower entities.""" entity_description: LawnMowerEntityEntityDescription @@ -83,12 +95,12 @@ class LawnMowerEntity(Entity): return None return str(activity) - @property + @cached_property def activity(self) -> LawnMowerActivity | None: """Return the current lawn mower activity.""" return self._attr_activity - @property + @cached_property def supported_features(self) -> LawnMowerEntityFeature: """Flag lawn mower features that are supported.""" return self._attr_supported_features From 24b14d07de3cb51b41e09ffc1dce3abda372fd9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 14:29:18 -1000 Subject: [PATCH 698/927] Add support for attribute caching to the water_heater platform (#106336) --- .../components/water_heater/__init__.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index c780407af7c..8e336533fc3 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -39,6 +39,12 @@ from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + DEFAULT_MIN_TEMP = 110 DEFAULT_MAX_TEMP = 140 @@ -174,7 +180,19 @@ class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=Tru """A class that describes water heater entities.""" -class WaterHeaterEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_operation", + "operation_list", + "current_temperature", + "target_temperature", + "target_temperature_high", + "target_temperature_low", + "is_away_mode_on", +} + + +class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for water heater entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -268,42 +286,42 @@ class WaterHeaterEntity(Entity): return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_operation(self) -> str | None: """Return current operation ie. eco, electric, performance, ...""" return self._attr_current_operation - @property + @cached_property def operation_list(self) -> list[str] | None: """Return the list of available operation modes.""" return self._attr_operation_list - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" return self._attr_target_temperature_low - @property + @cached_property def is_away_mode_on(self) -> bool | None: """Return true if away mode is on.""" return self._attr_is_away_mode_on From b6c2842b0194cf0de135efd1ef1f3eee414db7f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 15:14:09 -1000 Subject: [PATCH 699/927] Add support for attribute caching to the date platform (#106338) --- homeassistant/components/date/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 7426293cfb4..00ec09043c9 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import date, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -21,6 +21,12 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -65,7 +71,10 @@ class DateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date entities.""" -class DateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class DateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date entity.""" entity_description: DateEntityDescription @@ -73,13 +82,13 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -93,7 +102,7 @@ class DateEntity(Entity): return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> date | None: """Return the value reported by the date.""" return self._attr_native_value From b5e107406278a1a98838d743bd2aead209a4ab65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 15:14:31 -1000 Subject: [PATCH 700/927] Add support for attribute caching to the time platform (#106339) --- homeassistant/components/time/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 2b5721aaf1b..387c42f0852 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import time, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -21,6 +21,12 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -65,7 +71,10 @@ class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes time entities.""" -class TimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription @@ -73,13 +82,13 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -93,7 +102,7 @@ class TimeEntity(Entity): return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> time | None: """Return the value reported by the time.""" return self._attr_native_value From 2a52453f5dc247eda487797e379beb20bd68d3bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 15:14:48 -1000 Subject: [PATCH 701/927] Add support for attribute caching to the datetime platform (#106340) --- homeassistant/components/datetime/__init__.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index 823028ee6a7..9a509aadc70 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -21,6 +21,11 @@ from homeassistant.util import dt as dt_util from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -74,7 +79,12 @@ class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date/time entities.""" -class DateTimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_value", +} + + +class DateTimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date/time entity.""" entity_description: DateTimeEntityDescription @@ -82,13 +92,13 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @property + @cached_property @final def device_class(self) -> None: """Return entity device class.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -108,7 +118,7 @@ class DateTimeEntity(Entity): return value.astimezone(UTC).isoformat(timespec="seconds") - @property + @cached_property def native_value(self) -> datetime | None: """Return the value reported by the datetime.""" return self._attr_native_value From 278c7ac2a5fcc4bac7fa0e358cb825ef6a1e7341 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 15:15:09 -1000 Subject: [PATCH 702/927] Add support for attribute caching to the todo platform (#106341) --- homeassistant/components/todo/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index c0e0303d76e..d94233a20b9 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable import dataclasses import datetime import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -41,6 +41,12 @@ from .const import ( TodoListEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(seconds=60) @@ -226,7 +232,12 @@ class TodoItem: """ -class TodoListEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "todo_items", +} + + +class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None @@ -240,7 +251,7 @@ class TodoListEntity(Entity): return None return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items]) - @property + @cached_property def todo_items(self) -> list[TodoItem] | None: """Return the To-do items in the To-do list.""" return self._attr_todo_items From e469c6892b29a3580507b510cf8c2867e19d7986 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 24 Dec 2023 02:16:15 +0100 Subject: [PATCH 703/927] Add Airnow to strict typing (#105566) --- .strict-typing | 1 + .../components/airnow/config_flow.py | 35 ++++++++++++------- .../components/airnow/coordinator.py | 19 +++++++--- mypy.ini | 10 ++++++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.strict-typing b/.strict-typing index d83bd4d7adb..aa9c801fbf6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -48,6 +48,7 @@ homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airnow.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d72d145f7de..a6fa7aa5088 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -6,8 +6,17 @@ from pyairnow import WebServiceAPI from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol -from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant import core +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -16,7 +25,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -46,12 +55,14 @@ async def validate_input(hass: core.HomeAssistant, data): return True -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for AirNow.""" VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -108,18 +119,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: + config_entry: ConfigEntry, + ) -> OptionsFlow: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return AirNowOptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): +class AirNowOptionsFlowHandler(OptionsFlowWithConfigEntry): """Handle an options flow for AirNow.""" async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(data=user_input) @@ -141,13 +152,13 @@ class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): ) -class CannotConnect(exceptions.HomeAssistantError): +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" -class InvalidLocation(exceptions.HomeAssistantError): +class InvalidLocation(HomeAssistantError): """Error to indicate the location is invalid.""" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index e89afc2f7ce..4bdaadff0da 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -1,11 +1,15 @@ """DataUpdateCoordinator for the AirNow integration.""" +from datetime import timedelta import logging +from typing import Any +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration from pyairnow.errors import AirNowError +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -31,12 +35,19 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class AirNowDataUpdateCoordinator(DataUpdateCoordinator): +class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The AirNow update coordinator.""" def __init__( - self, hass, session, api_key, latitude, longitude, distance, update_interval - ): + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + distance: int, + update_interval: timedelta, + ) -> None: """Initialize.""" self.latitude = latitude self.longitude = longitude @@ -46,7 +57,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" data = {} try: diff --git a/mypy.ini b/mypy.ini index db175cc13f1..e19c6c6fa92 100644 --- a/mypy.ini +++ b/mypy.ini @@ -240,6 +240,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airnow.*] +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.airvisual.*] check_untyped_defs = true disallow_incomplete_defs = true From c7cb5088961bb5caaad97ad79749d4b5cb4bc89f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 24 Dec 2023 02:34:36 +0100 Subject: [PATCH 704/927] Add diagnostics to ccm15 (#106329) * Add diagnostics to ccm15 * Update homeassistant/components/ccm15/diagnostics.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- homeassistant/components/ccm15/diagnostics.py | 35 ++++++++++++++++++ .../ccm15/snapshots/test_diagnostics.ambr | 33 +++++++++++++++++ tests/components/ccm15/test_diagnostics.py | 37 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 homeassistant/components/ccm15/diagnostics.py create mode 100644 tests/components/ccm15/snapshots/test_diagnostics.ambr create mode 100644 tests/components/ccm15/test_diagnostics.py diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py new file mode 100644 index 00000000000..b4a3c80f319 --- /dev/null +++ b/homeassistant/components/ccm15/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for CCM15.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + str(device_id): { + "is_celsius": device.is_celsius, + "locked_cool_temperature": device.locked_cool_temperature, + "locked_heat_temperature": device.locked_heat_temperature, + "locked_ac_mode": device.locked_ac_mode, + "error_code": device.error_code, + "ac_mode": device.ac_mode, + "fan_mode": device.fan_mode, + "is_ac_mode_locked": device.is_ac_mode_locked, + "temperature_setpoint": device.temperature_setpoint, + "fan_locked": device.fan_locked, + "is_remote_locked": device.is_remote_locked, + "temperature": device.temperature, + } + for device_id, device in coordinator.data.devices.items() + } diff --git a/tests/components/ccm15/snapshots/test_diagnostics.ambr b/tests/components/ccm15/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c6b2f9c371e --- /dev/null +++ b/tests/components/ccm15/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '0': dict({ + 'ac_mode': 4, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 5, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 27, + 'temperature_setpoint': 23, + }), + '1': dict({ + 'ac_mode': 0, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 2, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 26, + 'temperature_setpoint': 24, + }), + }) +# --- diff --git a/tests/components/ccm15/test_diagnostics.py b/tests/components/ccm15/test_diagnostics.py new file mode 100644 index 00000000000..3700faa51ce --- /dev/null +++ b/tests/components/ccm15/test_diagnostics.py @@ -0,0 +1,37 @@ +"""Test CCM15 diagnostics.""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +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 + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ccm15_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot From 3d6fb60e93c0337a9fa7dc0480fce44e10945c2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Dec 2023 15:36:45 -1000 Subject: [PATCH 705/927] Add support for attribute caching to the vacuum platform (#106332) --- homeassistant/components/vacuum/__init__.py | 50 ++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 5ffb3de2a12..04265dcf63d 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -7,7 +7,7 @@ from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -45,6 +45,11 @@ from homeassistant.loader import ( bind_hass, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "vacuum" @@ -225,7 +230,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class _BaseVacuum(Entity): +BASE_CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "battery_level", + "battery_icon", + "fan_speed", + "fan_speed_list", +} + + +class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): """Representation of a base vacuum. Contains common properties and functions for all vacuum devices. @@ -239,27 +253,27 @@ class _BaseVacuum(Entity): _attr_fan_speed_list: list[str] _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) - @property + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features - @property + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" return self._attr_battery_level - @property + @cached_property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" return self._attr_battery_icon - @property + @cached_property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self._attr_fan_speed - @property + @cached_property def fan_speed_list(self) -> list[str]: """Get the list of available fan speed steps of the vacuum cleaner.""" return self._attr_fan_speed_list @@ -370,7 +384,14 @@ class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class VacuumEntity(_BaseVacuum, ToggleEntity): +VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "status", +} + + +class VacuumEntity( + _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot.""" @callback @@ -428,7 +449,7 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): entity_description: VacuumEntityDescription _attr_status: str | None = None - @property + @cached_property def status(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._attr_status @@ -492,13 +513,20 @@ class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class StateVacuumEntity(_BaseVacuum): +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "state", +} + + +class StateVacuumEntity( + _BaseVacuum, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot that supports states.""" entity_description: StateVacuumEntityDescription _attr_state: str | None = None - @property + @cached_property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" return self._attr_state From fc349b08755d115356700cb5e71f0792d0a7fa42 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sat, 23 Dec 2023 21:11:51 -0500 Subject: [PATCH 706/927] Missing exception on relogin in Honeywell (#106324) Missing exception on relogin --- homeassistant/components/honeywell/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 7281c5740ef..2f06dd1cfbe 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -501,6 +501,7 @@ class HoneywellUSThermostat(ClimateEntity): except ( AuthError, ClientConnectionError, + AscConnectionError, asyncio.TimeoutError, ): self._retry += 1 From 4ee0666efd026d47c07cf410069ddf37cc3c181e Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 24 Dec 2023 12:22:53 +0100 Subject: [PATCH 707/927] Bump openwebifpy to 4.0.2 (#106345) bump openwebifpy to 4.0.2 --- homeassistant/components/enigma2/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index ab930ba540d..7909db3b7c7 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.0.0"] + "requirements": ["openwebifpy==4.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f802ee0cf2d..22747d48ec7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.0.0 +openwebifpy==4.0.2 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 From 0a4e82f190cd195ef2c18eace3683b3a9d15a57f Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 24 Dec 2023 11:23:26 +0000 Subject: [PATCH 708/927] Bump temperusb to 1.6.1 (#106346) --- homeassistant/components/temper/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json index 527a4b95b71..dbad8827877 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/temper", "iot_class": "local_polling", "loggers": ["pyusb", "temperusb"], - "requirements": ["temperusb==1.6.0"] + "requirements": ["temperusb==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22747d48ec7..03b88654ba3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2615,7 +2615,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.tensorflow # tensorflow==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b55ea4e71d1..cd8856fa739 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1964,7 +1964,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.powerwall tesla-powerwall==0.3.19 From 771409579a1b86589ff98b8b73a68ed30c36bae7 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Sun, 24 Dec 2023 09:03:10 -0500 Subject: [PATCH 709/927] Add select platform to drop_connect integration (#106309) * Add select platform to drop_connect integration * Fix select test * Fix minor issues * Make function definition more specific * Revert change to switch.py for inclusion in separate PR * Improve typing * Add translation keys for select options * Fix set function typing * Remove redundant value check * Remove args that match defaults --- .../components/drop_connect/__init__.py | 7 +- .../components/drop_connect/coordinator.py | 13 ++- .../components/drop_connect/select.py | 95 +++++++++++++++++++ .../components/drop_connect/strings.json | 10 ++ tests/components/drop_connect/common.py | 4 +- tests/components/drop_connect/test_select.py | 59 ++++++++++++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/drop_connect/select.py create mode 100644 tests/components/drop_connect/test_select.py diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index f24cc9dba3b..7bfab762f99 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -15,7 +15,12 @@ from .coordinator import DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index 3f6110de9b3..67409528402 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -25,7 +25,7 @@ class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") self.drop_api = DropAPI() - async def set_water(self, value: int): + async def set_water(self, value: int) -> None: """Change water supply state.""" payload = self.drop_api.set_water_message(value) await mqtt.async_publish( @@ -34,7 +34,7 @@ class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): payload, ) - async def set_bypass(self, value: int): + async def set_bypass(self, value: int) -> None: """Change water bypass state.""" payload = self.drop_api.set_bypass_message(value) await mqtt.async_publish( @@ -42,3 +42,12 @@ class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): self.config_entry.data[CONF_COMMAND_TOPIC], payload, ) + + async def set_protect_mode(self, value: str) -> None: + """Change protect mode state.""" + payload = self.drop_api.set_protect_mode_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py new file mode 100644 index 00000000000..365345e147d --- /dev/null +++ b/homeassistant/components/drop_connect/select.py @@ -0,0 +1,95 @@ +"""Support for DROP selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +# Select type constants +PROTECT_MODE = "protect_mode" + +PROTECT_MODE_OPTIONS = ["away", "home", "schedule"] + +FLOOD_ICON = "mdi:home-flood" + + +@dataclass(kw_only=True, frozen=True) +class DROPSelectEntityDescription(SelectEntityDescription): + """Describes DROP select entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], str | None] + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, str], Awaitable[Any]] + + +SELECTS: list[DROPSelectEntityDescription] = [ + DROPSelectEntityDescription( + key=PROTECT_MODE, + translation_key=PROTECT_MODE, + icon=FLOOD_ICON, + options=PROTECT_MODE_OPTIONS, + value_fn=lambda device: device.drop_api.protect_mode(), + set_fn=lambda device, value: device.set_protect_mode(value), + ) +] + +# Defines which selects are used by each device type +DEVICE_SELECTS: dict[str, list[str]] = { + DEV_HUB: [PROTECT_MODE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP selects from config entry.""" + _LOGGER.debug( + "Set up select for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: + async_add_entities( + DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + for select in SELECTS + if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSelect(DROPEntity, SelectEntity): + """Representation of a DROP select.""" + + entity_description: DROPSelectEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self.entity_description.value_fn(self.coordinator) + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + await self.entity_description.set_fn(self.coordinator, option) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 03f16f42070..761d134bd18 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -33,6 +33,16 @@ "salt": { "name": "Salt low" }, "pump": { "name": "Pump status" } }, + "select": { + "protect_mode": { + "name": "Protect mode", + "state": { + "away": "Away", + "home": "Home", + "schedule": "Schedule" + } + } + }, "switch": { "water": { "name": "Water supply" }, "bypass": { "name": "Treatment bypass" } diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py index e7908831811..ea96af03617 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -3,11 +3,11 @@ TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" TEST_DATA_HUB = ( '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' - '"water":1,"bypass":0,"pMode":"HOME","battery":50,"notif":1,"leak":0}' + '"water":1,"bypass":0,"pMode":"home","battery":50,"notif":1,"leak":0}' ) TEST_DATA_HUB_RESET = ( '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' - '"water":0,"bypass":1,"pMode":"AWAY","battery":0,"notif":0,"leak":0}' + '"water":0,"bypass":1,"pMode":"away","battery":0,"notif":0,"leak":0}' ) TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" diff --git a/tests/components/drop_connect/test_select.py b/tests/components/drop_connect/test_select.py new file mode 100644 index 00000000000..24877069367 --- /dev/null +++ b/tests/components/drop_connect/test_select.py @@ -0,0 +1,59 @@ +"""Test DROP select entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_selects_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.attributes.get(ATTR_OPTIONS) == [ + "away", + "home", + "schedule", + ] + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "home" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "away", ATTR_ENTITY_ID: protect_mode_select_name}, + blocking=True, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "away" From 9066555feb96c59442ad9c8214dec0b28c9194bb Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 24 Dec 2023 16:47:22 +0100 Subject: [PATCH 710/927] Deprecate parameter "retries" in modbus (#105024) --- homeassistant/components/modbus/modbus.py | 20 +++++++++++++++++++- homeassistant/components/modbus/strings.json | 4 ++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c0474ad75d5..1d755adace7 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -260,6 +260,24 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_RETRIES in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retries", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retries", + translation_placeholders={ + "config_key": "retries", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retries`: is deprecated and will be removed in version 2024.7" + ) if CONF_CLOSE_COMM_ON_ERROR in client_config: async_create_issue( hass, @@ -315,7 +333,7 @@ class ModbusHub: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "retries": client_config[CONF_RETRIES], + "retries": 3, "retry_on_empty": True, } if self._config_type == SERIAL: diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index c549b59bf8f..12e66f5d2ca 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -74,6 +74,10 @@ "title": "`{config_key}` configuration key is being removed", "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" }, + "deprecated_retries": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." From f45f0b432732c0b664ed6a0a712009c1c72de9f8 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sun, 24 Dec 2023 13:51:43 -0800 Subject: [PATCH 711/927] Split out shared screenlogic switch code (#106344) --- .../components/screenlogic/binary_sensor.py | 4 +-- .../components/screenlogic/entity.py | 21 ++++++++----- .../components/screenlogic/number.py | 4 +-- .../components/screenlogic/sensor.py | 4 +-- .../components/screenlogic/switch.py | 30 +++++++++++-------- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index cb73fab90ee..096c2c22918 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -232,7 +232,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): +class ScreenLogicBinarySensor(ScreenLogicEntity, BinarySensorEntity): """Representation of a ScreenLogic binary sensor entity.""" entity_description: ScreenLogicBinarySensorDescription diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 06551c2736b..fc2c855d682 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -44,7 +44,7 @@ class ScreenLogicEntityDescription( enabled_lambda: Callable[..., bool] | None = None -class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): +class ScreenLogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" entity_description: ScreenLogicEntityDescription @@ -118,7 +118,7 @@ class ScreenLogicPushEntityDescription( """Base class for a ScreenLogic push entity description.""" -class ScreenLogicPushEntity(ScreenlogicEntity): +class ScreenLogicPushEntity(ScreenLogicEntity): """Base class for all ScreenLogic push entities.""" entity_description: ScreenLogicPushEntityDescription @@ -157,8 +157,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity): self._async_data_updated() -class ScreenLogicCircuitEntity(ScreenLogicPushEntity): - """Base class for all ScreenLogic switch and light entities.""" +class ScreenLogicSwitchingEntity(ScreenLogicEntity): + """Base class for all switchable entities.""" @property def is_on(self) -> bool: @@ -167,13 +167,20 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" - await self._async_set_circuit(ON_OFF.ON) + await self._async_set_state(ON_OFF.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" - await self._async_set_circuit(ON_OFF.OFF) + await self._async_set_state(ON_OFF.OFF) - async def _async_set_circuit(self, state: ON_OFF) -> None: + async def _async_set_state(self, state: ON_OFF) -> None: + raise NotImplementedError() + + +class ScreenLogicCircuitEntity(ScreenLogicSwitchingEntity, ScreenLogicPushEntity): + """Base class for all ScreenLogic circuit switch and light entities.""" + + async def _async_set_state(self, state: ON_OFF) -> None: try: await self.gateway.async_set_circuit(self._data_key, state.value) except (ScreenLogicCommunicationError, ScreenLogicError) as sle: diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a275705f646..cc5efa6c7ad 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .entity import ScreenLogicEntity, ScreenLogicEntityDescription from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -87,7 +87,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): +class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): """Class to represent a ScreenLogic Number entity.""" entity_description: ScreenLogicNumberDescription diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 87bc101a074..c73ce8be42c 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -295,7 +295,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): +class ScreenLogicSensor(ScreenLogicEntity, SensorEntity): """Representation of a ScreenLogic sensor entity.""" entity_description: ScreenLogicSensorDescription diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index e64f7a3a164..43f749db913 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -13,18 +13,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription +from .entity import ( + ScreenLogicCircuitEntity, + ScreenLogicPushEntityDescription, + ScreenLogicSwitchingEntity, +) _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True) +class ScreenLogicCircuitSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSwitch] = [] + entities: list[ScreenLogicSwitchingEntity] = [] coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] @@ -39,9 +50,9 @@ async def async_setup_entry( circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) entities.append( - ScreenLogicSwitch( + ScreenLogicCircuitSwitch( coordinator, - ScreenLogicSwitchDescription( + ScreenLogicCircuitSwitchDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CIRCUIT,), key=circuit_index, @@ -56,14 +67,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass(frozen=True) -class ScreenLogicSwitchDescription( - SwitchEntityDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogic switch entity.""" - - -class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): +class ScreenLogicCircuitSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" - entity_description: ScreenLogicSwitchDescription + entity_description: ScreenLogicCircuitSwitchDescription From d59142a59517c42c000b2dcf6f5ea37ef380c391 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 25 Dec 2023 07:02:57 +0100 Subject: [PATCH 712/927] Add missing sensors for Shelly Pro Dimmer 2PM (#105008) Add missing sensors for Shelly Pro Dimmer 2 --- homeassistant/components/shelly/sensor.py | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index fa5f51b00b0..89dc10f0530 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -372,6 +372,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_light": RpcSensorDescription( + key="light", + sub_key="apower", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -502,6 +510,17 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_light": RpcSensorDescription( + key="light", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -560,6 +579,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_light": RpcSensorDescription( + key="light", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -628,6 +657,17 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "energy_light": RpcSensorDescription( + key="light", + sub_key="aenergy", + name="Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), "energy_pm1": RpcSensorDescription( key="pm1", sub_key="aenergy", @@ -838,6 +878,19 @@ RPC_SENSORS: Final = { entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), + "temperature_light": RpcSensorDescription( + key="light", + sub_key="temperature", + name="Device temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda status, _: status["tC"], + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + use_polling_coordinator=True, + ), "temperature_0": RpcSensorDescription( key="temperature", sub_key="tC", From 4c11cb78c855549230a1eeb5c769a525716882ee Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 25 Dec 2023 21:01:59 +1000 Subject: [PATCH 713/927] Add delay to manual refresh in Advantage Air (#104918) * Add debouncer * Avoid having 3 calls * Debounce causes extra refresh in reload window * Seperate disabled test to avoid latent refresh --------- Co-authored-by: G Johansson --- homeassistant/components/advantage_air/__init__.py | 5 +++++ homeassistant/components/advantage_air/entity.py | 2 +- tests/components/advantage_air/test_sensor.py | 8 ++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 4dbc2edad8d..1383ea7c054 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN @@ -26,6 +27,7 @@ PLATFORMS = [ ] _LOGGER = logging.getLogger(__name__) +REQUEST_REFRESH_DELAY = 0.5 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,6 +53,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="Advantage Air", update_method=async_get, update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 691db99769b..9079e69ae09 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -30,7 +30,7 @@ class AdvantageAirEntity(CoordinatorEntity): async def update_handle(*values): try: if await func(*keys, *values): - await self.coordinator.async_refresh() + await self.coordinator.async_request_refresh() except ApiError as err: raise HomeAssistantError(err) from err diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index a7483e680b3..0099e1844c6 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -109,6 +109,14 @@ async def test_sensor_platform( assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" + +async def test_sensor_platform_disabled_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_get: AsyncMock +) -> None: + """Test sensor platform disabled entity.""" + + await add_mock_config(hass) + # Test First Zone Temp Sensor (disabled by default) entity_id = "sensor.myzone_zone_open_with_sensor_temperature" From 123b2669f3a275b5aa4d01d9eac2c6ba97ed1386 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 25 Dec 2023 12:04:07 +0100 Subject: [PATCH 714/927] Add full coverage to Fast.com (#105596) * Add full test coverage for Fast.com * Remove not needed * Add service deprecated test * Rename speedtest * Extend delay test --- .coveragerc | 2 - .../components/fastdotcom/test_coordinator.py | 9 +- tests/components/fastdotcom/test_init.py | 115 ++++++++++++++++++ tests/components/fastdotcom/test_sensor.py | 31 +++++ 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 tests/components/fastdotcom/test_init.py create mode 100644 tests/components/fastdotcom/test_sensor.py diff --git a/.coveragerc b/.coveragerc index fe2ba33b0aa..1ceca701d50 100644 --- a/.coveragerc +++ b/.coveragerc @@ -363,8 +363,6 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py - homeassistant/components/fastdotcom/sensor.py - homeassistant/components/fastdotcom/__init__.py homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/__init__.py homeassistant/components/fibaro/binary_sensor.py diff --git a/tests/components/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py index 5ee8c76092b..f51f0254714 100644 --- a/tests/components/fastdotcom/test_coordinator.py +++ b/tests/components/fastdotcom/test_coordinator.py @@ -4,7 +4,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.fastdotcom.const import DOMAIN +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN from homeassistant.components.fastdotcom.coordinator import DEFAULT_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -19,6 +19,7 @@ async def test_fastdotcom_data_update_coordinator( config_entry = MockConfigEntry( domain=DOMAIN, unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, ) config_entry.add_to_hass(hass) @@ -28,7 +29,7 @@ async def test_fastdotcom_data_update_coordinator( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.mock_title_download") + state = hass.states.get("sensor.fast_com_download") assert state is not None assert state.state == "5.0" @@ -39,7 +40,7 @@ async def test_fastdotcom_data_update_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.mock_title_download") + state = hass.states.get("sensor.fast_com_download") assert state.state == "10.0" with patch( @@ -50,5 +51,5 @@ async def test_fastdotcom_data_update_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.mock_title_download") + state = hass.states.get("sensor.fast_com_download") assert state.state is STATE_UNAVAILABLE diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py new file mode 100644 index 00000000000..0acaddf36fc --- /dev/null +++ b/tests/components/fastdotcom/test_init.py @@ -0,0 +1,115 @@ +"""Test for Sensibo component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant import config_entries +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, STATE_UNKNOWN +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload an entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_from_import(hass: HomeAssistant) -> None: + """Test imported entry.""" + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await async_setup_component( + hass, + DOMAIN, + {"fastdotcom": {}}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + +async def test_delayed_speedtest_during_startup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test delayed speedtest during startup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ), patch.object(hass, "state", CoreState.starting): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + state = hass.states.get("sensor.fast_com_download") + assert state is not None + # Assert state is unknown as coordinator is not allowed to start and fetch data yet + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "0" + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_service_deprecated( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test deprecated service.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + "speedtest", + {}, + blocking=True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "service_deprecation") + assert issue + assert issue.is_fixable is True + assert issue.translation_key == "service_deprecation" diff --git a/tests/components/fastdotcom/test_sensor.py b/tests/components/fastdotcom/test_sensor.py new file mode 100644 index 00000000000..47826bf35cf --- /dev/null +++ b/tests/components/fastdotcom/test_sensor.py @@ -0,0 +1,31 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" From 04a56eaabe8c56bbe42e59f50191a06d6a446ab3 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 25 Dec 2023 22:01:13 +1000 Subject: [PATCH 715/927] Add data model to Tessie (#106285) Add data model --- homeassistant/components/tessie/__init__.py | 19 +++++++++++-------- .../components/tessie/binary_sensor.py | 12 ++++++------ homeassistant/components/tessie/button.py | 10 +++++----- homeassistant/components/tessie/climate.py | 10 ++++++---- .../components/tessie/coordinator.py | 2 +- homeassistant/components/tessie/cover.py | 12 ++++++------ .../components/tessie/device_tracker.py | 10 +++++----- homeassistant/components/tessie/entity.py | 6 +++--- homeassistant/components/tessie/lock.py | 8 ++++---- .../components/tessie/media_player.py | 8 ++++---- homeassistant/components/tessie/models.py | 13 +++++++++++++ homeassistant/components/tessie/number.py | 12 ++++++------ homeassistant/components/tessie/select.py | 8 ++++---- homeassistant/components/tessie/sensor.py | 12 ++++++------ homeassistant/components/tessie/switch.py | 12 ++++++------ homeassistant/components/tessie/update.py | 10 ++++++---- 16 files changed, 92 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/tessie/models.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index f344cef2484..869cd46cf51 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -12,7 +12,8 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator +from .models import TessieVehicle PLATFORMS = [ Platform.BINARY_SENSOR, @@ -50,18 +51,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ClientError as e: raise ConfigEntryNotReady from e - coordinators = [ - TessieDataUpdateCoordinator( - hass, - api_key=api_key, - vin=vehicle["vin"], - data=vehicle["last_state"], + data = [ + TessieVehicle( + state_coordinator=TessieStateUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) ) for vehicle in vehicles["results"] if vehicle["last_state"] is not None ] - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 38473d1076b..aab20763609 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TessieStatus -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -117,13 +117,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie binary sensor platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TessieBinarySensorEntity(coordinator, description) - for coordinator in coordinators + TessieBinarySensorEntity(vehicle.state_coordinator, description) + for vehicle in data for description in DESCRIPTIONS - if description.key in coordinator.data + if description.key in vehicle.state_coordinator.data ) @@ -134,7 +134,7 @@ class TessieBinarySensorEntity(TessieEntity, BinarySensorEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, description: TessieBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index 5d02d9fe8aa..df918d057a2 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -62,11 +62,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie Button platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TessieButtonEntity(coordinator, description) - for coordinator in coordinators + TessieButtonEntity(vehicle.state_coordinator, description) + for vehicle in data for description in DESCRIPTIONS ) @@ -78,7 +78,7 @@ class TessieButtonEntity(TessieEntity, ButtonEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, description: TessieButtonEntityDescription, ) -> None: """Initialize the Button.""" diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 48fe73919cd..8d27305cb0b 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TessieClimateKeeper -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -29,9 +29,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie Climate platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] - async_add_entities(TessieClimateEntity(coordinator) for coordinator in coordinators) + async_add_entities( + TessieClimateEntity(vehicle.state_coordinator) for vehicle in data + ) class TessieClimateEntity(TessieEntity, ClimateEntity): @@ -54,7 +56,7 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, ) -> None: """Initialize the Climate entity.""" super().__init__(coordinator, "primary") diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 0fdfbcc5345..c2f53da53bc 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -20,7 +20,7 @@ TESSIE_SYNC_INTERVAL = 10 _LOGGER = logging.getLogger(__name__) -class TessieDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Tessie API.""" def __init__( diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index b7834a74766..dddda068d61 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -28,15 +28,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie sensor platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - Entity(coordinator) + Entity(vehicle.state_coordinator) for Entity in ( TessieWindowEntity, TessieChargePortEntity, ) - for coordinator in coordinators + for vehicle in data ) @@ -46,7 +46,7 @@ class TessieWindowEntity(TessieEntity, CoverEntity): _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - def __init__(self, coordinator: TessieDataUpdateCoordinator) -> None: + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator, "windows") @@ -87,7 +87,7 @@ class TessieChargePortEntity(TessieEntity, CoverEntity): _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - def __init__(self, coordinator: TessieDataUpdateCoordinator) -> None: + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator, "charge_state_charge_port_door_open") diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index 330623e55b4..2652a6247c8 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -17,15 +17,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie device tracker platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - klass(coordinator) + klass(vehicle.state_coordinator) for klass in ( TessieDeviceTrackerLocationEntity, TessieDeviceTrackerRouteEntity, ) - for coordinator in coordinators + for vehicle in data ) @@ -34,7 +34,7 @@ class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, ) -> None: """Initialize the device tracker.""" super().__init__(coordinator, self.key) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index b7c04d35306..ecd7f863542 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -10,17 +10,17 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODELS -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator -class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): +class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): """Parent class for Tessie Entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, key: str, ) -> None: """Initialize common aspects of a Tessie entity.""" diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 3342747a2f9..25b9de4b579 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -19,9 +19,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie sensor platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] - async_add_entities(TessieLockEntity(coordinator) for coordinator in coordinators) + async_add_entities(TessieLockEntity(vehicle.state_coordinator) for vehicle in data) class TessieLockEntity(TessieEntity, LockEntity): @@ -31,7 +31,7 @@ class TessieLockEntity(TessieEntity, LockEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, "vehicle_state_locked") diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index ffbb6619668..544290de093 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity STATES = { @@ -25,9 +25,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie Media platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] - async_add_entities(TessieMediaEntity(coordinator) for coordinator in coordinators) + async_add_entities(TessieMediaEntity(vehicle.state_coordinator) for vehicle in data) class TessieMediaEntity(TessieEntity, MediaPlayerEntity): @@ -38,7 +38,7 @@ class TessieMediaEntity(TessieEntity, MediaPlayerEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, ) -> None: """Initialize the media player entity.""" super().__init__(coordinator, "media") diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py new file mode 100644 index 00000000000..32466a6b2ac --- /dev/null +++ b/homeassistant/components/tessie/models.py @@ -0,0 +1,13 @@ +"""The Tessie integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .coordinator import TessieStateUpdateCoordinator + + +@dataclass +class TessieVehicle: + """Data for the Tessie integration.""" + + state_coordinator: TessieStateUpdateCoordinator diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index b7c0e145d7b..ada088f1bd2 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -83,13 +83,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie sensor platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TessieNumberEntity(coordinator, description) - for coordinator in coordinators + TessieNumberEntity(vehicle.state_coordinator, description) + for vehicle in data for description in DESCRIPTIONS - if description.key in coordinator.data + if description.key in vehicle.state_coordinator.data ) @@ -100,7 +100,7 @@ class TessieNumberEntity(TessieEntity, NumberEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, description: TessieNumberEntityDescription, ) -> None: """Initialize the Number entity.""" diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index d40abed6478..03436b44cfc 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -26,13 +26,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie select platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TessieSeatHeaterSelectEntity(coordinator, key) - for coordinator in coordinators + TessieSeatHeaterSelectEntity(vehicle.state_coordinator, key) + for vehicle in data for key in SEAT_HEATERS - if key in coordinator.data + if key in vehicle.state_coordinator.data ) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 9023a3319ea..aaf37e51d61 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -188,13 +188,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie sensor platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TessieSensorEntity(coordinator, description) - for coordinator in coordinators + TessieSensorEntity(vehicle.state_coordinator, description) + for vehicle in data for description in DESCRIPTIONS - if description.key in coordinator.data + if description.key in vehicle.state_coordinator.data ) @@ -205,7 +205,7 @@ class TessieSensorEntity(TessieEntity, SensorEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, description: TessieSensorEntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 2dd54cf7ed1..595c44e11be 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -78,14 +78,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie Switch platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - TessieSwitchEntity(coordinator, description) - for coordinator in coordinators + TessieSwitchEntity(vehicle.state_coordinator, description) + for vehicle in data for description in DESCRIPTIONS - if description.key in coordinator.data + if description.key in vehicle.state_coordinator.data ] ) @@ -98,7 +98,7 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, description: TessieSwitchEntityDescription, ) -> None: """Initialize the Switch.""" diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 4a3c06df6e2..9628b580697 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TessieUpdateStatus -from .coordinator import TessieDataUpdateCoordinator +from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -15,9 +15,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Tessie Update platform from a config entry.""" - coordinators = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] - async_add_entities(TessieUpdateEntity(coordinator) for coordinator in coordinators) + async_add_entities( + TessieUpdateEntity(vehicle.state_coordinator) for vehicle in data + ) class TessieUpdateEntity(TessieEntity, UpdateEntity): @@ -28,7 +30,7 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): def __init__( self, - coordinator: TessieDataUpdateCoordinator, + coordinator: TessieStateUpdateCoordinator, ) -> None: """Initialize the Update.""" super().__init__(coordinator, "update") From 3016dbc2bdd2af5716800e8479fce5fcd33b3510 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Dec 2023 15:40:17 +0100 Subject: [PATCH 716/927] Enable toggle on valve/cover start in google_assistant (#106378) --- .../components/google_assistant/trait.py | 49 ++-- .../components/google_assistant/test_trait.py | 225 +++++++++++++----- 2 files changed, 192 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 638dfb6eff5..9b8a95f0b4a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -209,6 +209,10 @@ SERVICE_CLOSE_COVER_VALVE = { cover.DOMAIN: cover.SERVICE_CLOSE_COVER, valve.DOMAIN: valve.SERVICE_CLOSE_VALVE, } +SERVICE_TOGGLE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_TOGGLE, + valve.DOMAIN: valve.SERVICE_TOGGLE, +} SERVICE_SET_POSITION_COVER_VALVE = { cover.DOMAIN: cover.SERVICE_SET_COVER_POSITION, valve.DOMAIN: valve.SERVICE_SET_VALVE_POSITION, @@ -228,6 +232,10 @@ COVER_VALVE_SET_POSITION_FEATURE = { cover.DOMAIN: CoverEntityFeature.SET_POSITION, valve.DOMAIN: ValveEntityFeature.SET_POSITION, } +COVER_VALVE_STOP_FEATURE = { + cover.DOMAIN: CoverEntityFeature.STOP, + valve.DOMAIN: ValveEntityFeature.STOP, +} COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN} @@ -846,10 +854,10 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return True - if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: - return True - - if domain == valve.DOMAIN and features & ValveEntityFeature.STOP: + if ( + domain in COVER_VALVE_DOMAINS + and features & COVER_VALVE_STOP_FEATURE[domain] + ): return True return False @@ -877,10 +885,14 @@ class StartStopTrait(_Trait): "isPaused": state == vacuum.STATE_PAUSED, } - if domain == cover.DOMAIN: - return {"isRunning": state in (cover.STATE_CLOSING, cover.STATE_OPENING)} - if domain == valve.DOMAIN: - return {"isRunning": True} + if domain in COVER_VALVE_DOMAINS: + return { + "isRunning": state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + } async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" @@ -932,15 +944,10 @@ class StartStopTrait(_Trait): domain = self.state.domain if command == COMMAND_STARTSTOP: if params["start"] is False: - if ( - self.state.state - in ( - COVER_VALVE_STATES[domain]["closing"], - COVER_VALVE_STATES[domain]["opening"], - ) - or domain == valve.DOMAIN - or self.state.attributes.get(ATTR_ASSUMED_STATE) - ): + if self.state.state in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) or self.state.attributes.get(ATTR_ASSUMED_STATE): await self.hass.services.async_call( domain, SERVICE_STOP_COVER_VALVE[domain], @@ -954,8 +961,12 @@ class StartStopTrait(_Trait): f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) else: - raise SmartHomeError( - ERR_NOT_SUPPORTED, f"Starting a {domain} is not supported" + await self.hass.services.async_call( + domain, + SERVICE_TOGGLE_COVER_VALVE[domain], + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, ) else: raise SmartHomeError( diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 3be0030f63e..3f1e28cb667 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -584,17 +584,71 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} -async def test_startstop_cover(hass: HomeAssistant) -> None: - """Test startStop trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.StartStopTrait.supported( - cover.DOMAIN, CoverEntityFeature.STOP, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: + """Test startStop trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.StartStopTrait.supported(domain, supported_features, None, None) state = State( - "cover.bla", - cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP}, + f"{domain}.bla", + state_closed, + {ATTR_SUPPORTED_FEATURES: supported_features}, ) trt = trait.StartStopTrait( @@ -605,25 +659,48 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} - for state_value in (cover.STATE_CLOSING, cover.STATE_OPENING): + for state_value in (state_closing, state_opening): state.state = state_value assert trt.query_attributes() == {"isRunning": True} - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + open_calls = async_mock_service(hass, domain, service_open) + close_calls = async_mock_service(hass, domain, service_close) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - for state_value in (cover.STATE_CLOSED, cover.STATE_OPEN): + for state_value in (state_closed, state_open): state.state = state_value assert trt.query_attributes() == {"isRunning": False} - with pytest.raises(SmartHomeError, match="Cover is already stopped"): + for state_value in (state_closing, state_opening): + state.state = state_value + assert trt.query_attributes() == {"isRunning": True} + + state.state = state_open + with pytest.raises( + SmartHomeError, match=f"{domain.capitalize()} is already stopped" + ): await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) - with pytest.raises(SmartHomeError, match="Starting a cover is not supported"): - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + # Start triggers toggle open + state.state = state_closed + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + # Second start triggers toggle close + state.state = state_open + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 2 + assert toggle_calls[1].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + state.state = state_closed with pytest.raises( SmartHomeError, match="Command action.devices.commands.PauseUnpause is not supported", @@ -631,67 +708,89 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {"start": True}, {}) -async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve_assumed( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: """Test startStop trait support for cover domain of assumed state.""" trt = trait.StartStopTrait( hass, State( - "cover.bla", - cover.STATE_CLOSED, + f"{domain}.bla", + state_closed, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: supported_features, ATTR_ASSUMED_STATE: True, }, ), BASIC_CONFIG, ) - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert len(toggle_calls) == 0 + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - -async def test_startstop_valve(hass: HomeAssistant) -> None: - """Test startStop trait support for valve domain.""" - assert helpers.get_google_type(valve.DOMAIN, None) is not None - assert trait.StartStopTrait.supported( - valve.DOMAIN, ValveEntityFeature.STOP, None, None - ) - assert not trait.StartStopTrait.supported( - valve.DOMAIN, ValveEntityFeature.SET_POSITION, None, None - ) - - state = State( - "valve.water", - valve.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: ValveEntityFeature.STOP}, - ) - - trt = trait.StartStopTrait( - hass, - state, - BASIC_CONFIG, - ) - - assert trt.sync_attributes() == {} - - for state_value in ( - valve.STATE_CLOSED, - valve.STATE_CLOSING, - valve.STATE_OPENING, - valve.STATE_OPEN, - ): - state.state = state_value - assert trt.query_attributes() == {"isRunning": True} - - stop_calls = async_mock_service(hass, valve.DOMAIN, valve.SERVICE_STOP_VALVE) - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) - assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "valve.water"} - - with pytest.raises(SmartHomeError, match="Starting a valve is not supported"): - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + stop_calls.clear() + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(stop_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) From 50f64e053e6f4cf63a1dc283477764105e406dd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Dec 2023 08:35:44 -1000 Subject: [PATCH 717/927] Use identity checks for sensor device class enums (#106383) --- homeassistant/components/sensor/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 69a99e3ac8e..9965fae9d59 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -511,7 +511,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( native_unit_of_measurement in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} - and self.device_class == SensorDeviceClass.TEMPERATURE + and self.device_class is SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -572,7 +572,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return None # Received a datetime - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class is SensorDeviceClass.TIMESTAMP: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -594,7 +594,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ) from err # Received a date value - if device_class == SensorDeviceClass.DATE: + if device_class is SensorDeviceClass.DATE: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -609,8 +609,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Enum checks if ( options := self.options - ) is not None or device_class == SensorDeviceClass.ENUM: - if device_class != SensorDeviceClass.ENUM: + ) is not None or device_class is SensorDeviceClass.ENUM: + if device_class is not SensorDeviceClass.ENUM: reason = "is missing the enum device class" if device_class is not None: reason = f"has device class '{device_class}' instead of 'enum'" From eb3fde7261bcd2140fcd68f5da64dfa41d6fa4ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Dec 2023 09:13:48 -1000 Subject: [PATCH 718/927] Avoid clearing the attr cache in setter when nothing has changed (#106384) --- homeassistant/helpers/entity.py | 5 +++++ tests/components/konnected/test_init.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bf30943fb58..8f344aff484 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -227,6 +227,9 @@ class EntityPlatformState(Enum): REMOVED = auto() +_SENTINEL = object() + + class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): """A class that describes Home Assistant entities.""" @@ -346,6 +349,8 @@ class CachedProperties(type): Also invalidates the corresponding cached_property by calling delattr on it. """ + if getattr(o, private_attr_name, _SENTINEL) == val: + return setattr(o, private_attr_name, val) try: # noqa: SIM105 suppress is much slower delattr(o, name) diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 2dff9672f17..658f1053f93 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -706,7 +706,7 @@ async def test_state_updates_zone( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"zone": "5", "temp": 32, "addr": 1}, + json={"zone": "5", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() @@ -863,7 +863,7 @@ async def test_state_updates_pin( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"pin": "7", "temp": 32, "addr": 1}, + json={"pin": "7", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() From f0e080f958f3ac82263a95632525c3181ceca1a0 Mon Sep 17 00:00:00 2001 From: Alessandro Mariotti Date: Mon, 25 Dec 2023 20:18:06 +0100 Subject: [PATCH 719/927] Bump getmac 0.9.4 (#106321) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/kef/manifest.json | 2 +- homeassistant/components/nmap_tracker/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 03c9942968c..e2a07a3e351 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index e2aec2fbcae..29e398994f4 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], - "requirements": ["aiokef==0.2.16", "getmac==0.8.2"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] } diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 2da0a4ae137..b9464020431 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -9,7 +9,7 @@ "loggers": ["nmap"], "requirements": [ "netmap==0.7.0.2", - "getmac==0.8.2", + "getmac==0.9.4", "mac-vendor-lookup==0.1.12" ] } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 6f5defe4c57..2b388cf706a 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,7 +35,7 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.8.2", + "getmac==0.9.4", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 4c3fdb65809..4b6badb0d3c 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/requirements_all.txt b/requirements_all.txt index 03b88654ba3..cc73ef173ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -903,7 +903,7 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios gios==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd8856fa739..b2a3dffbe41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios gios==3.2.2 From 6f9bff76024ec7d3656c7aab85683651bd7a2783 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 25 Dec 2023 23:19:28 -0500 Subject: [PATCH 720/927] Add config flow to Netgear LTE (#93002) * Add config flow to Netgear LTE * uno mas * uno mas * forgot one * uno mas * uno mas * apply suggestions * tweak user step * fix load/unload/dep * clean up * fix tests * test yaml before importing * uno mas * uno mas * uno mas * uno mas * uno mas * fix startup hanging * break out yaml import * fix doc string --------- Co-authored-by: Robert Resch --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/netgear_lte/__init__.py | 257 ++++++---- .../components/netgear_lte/binary_sensor.py | 36 +- .../components/netgear_lte/config_flow.py | 103 ++++ homeassistant/components/netgear_lte/const.py | 5 + .../components/netgear_lte/manifest.json | 1 + .../components/netgear_lte/notify.py | 4 +- .../components/netgear_lte/sensor.py | 50 +- .../components/netgear_lte/strings.json | 31 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/netgear_lte/__init__.py | 1 + tests/components/netgear_lte/conftest.py | 85 ++++ .../netgear_lte/fixtures/model.json | 450 ++++++++++++++++++ .../netgear_lte/test_binary_sensor.py | 19 + .../netgear_lte/test_config_flow.py | 110 +++++ tests/components/netgear_lte/test_init.py | 28 ++ tests/components/netgear_lte/test_notify.py | 29 ++ tests/components/netgear_lte/test_sensor.py | 56 +++ tests/components/netgear_lte/test_services.py | 55 +++ 22 files changed, 1169 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/netgear_lte/config_flow.py create mode 100644 tests/components/netgear_lte/__init__.py create mode 100644 tests/components/netgear_lte/conftest.py create mode 100644 tests/components/netgear_lte/fixtures/model.json create mode 100644 tests/components/netgear_lte/test_binary_sensor.py create mode 100644 tests/components/netgear_lte/test_config_flow.py create mode 100644 tests/components/netgear_lte/test_init.py create mode 100644 tests/components/netgear_lte/test_notify.py create mode 100644 tests/components/netgear_lte/test_sensor.py create mode 100644 tests/components/netgear_lte/test_services.py diff --git a/.coveragerc b/.coveragerc index 1ceca701d50..2e5748d2a98 100644 --- a/.coveragerc +++ b/.coveragerc @@ -804,7 +804,8 @@ omit = homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py homeassistant/components/netgear/update.py - homeassistant/components/netgear_lte/* + homeassistant/components/netgear_lte/__init__.py + homeassistant/components/netgear_lte/notify.py homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index e664d89a028..fda7f27c412 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -847,6 +847,7 @@ build.json @home-assistant/supervisor /homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG /tests/components/netgear/ @hacf-fr @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob +/tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core /tests/components/network/ @home-assistant/core /homeassistant/components/nexia/ @bdraco diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index d6ce3cb0994..c7dd2140555 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,12 +1,12 @@ """Support for Netgear LTE modems.""" -import asyncio from datetime import timedelta -import aiohttp +from aiohttp.cookiejar import CookieJar import attr import eternalegypt import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -16,11 +16,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from . import sensor_types @@ -32,6 +34,7 @@ from .const import ( CONF_BINARY_SENSOR, CONF_NOTIFY, CONF_SENSOR, + DATA_HASS_CONFIG, DISPATCHER_NETGEAR_LTE, DOMAIN, LOGGER, @@ -90,6 +93,12 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NOTIFY, + Platform.SENSOR, +] + @attr.s class ModemData: @@ -137,90 +146,108 @@ class LTEData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DOMAIN not in hass.data: - websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - hass.data[DOMAIN] = LTEData(websession) + hass.data[DATA_HASS_CONFIG] = config - await async_setup_services(hass) - - netgear_lte_config = config[DOMAIN] - - # Set up each modem - tasks = [ - hass.async_create_task(_setup_lte(hass, lte_conf)) - for lte_conf in netgear_lte_config - ] - await asyncio.wait(tasks) - - # Load platforms for each modem - for lte_conf in netgear_lte_config: - # Notify - for notify_conf in lte_conf[CONF_NOTIFY]: - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_NAME: notify_conf.get(CONF_NAME), - CONF_NOTIFY: notify_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, discovery_info, config - ) - ) - - # Sensor - sensor_conf = lte_conf[CONF_SENSOR] - discovery_info = {CONF_HOST: lte_conf[CONF_HOST], CONF_SENSOR: sensor_conf} - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.SENSOR, DOMAIN, discovery_info, config - ) - ) - - # Binary Sensor - binary_sensor_conf = lte_conf[CONF_BINARY_SENSOR] - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_BINARY_SENSOR: binary_sensor_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.BINARY_SENSOR, DOMAIN, discovery_info, config - ) - ) + if lte_config := config.get(DOMAIN): + await hass.async_create_task(import_yaml(hass, lte_config)) return True -async def _setup_lte(hass, lte_config): - """Set up a Netgear LTE modem.""" +async def import_yaml(hass: HomeAssistant, lte_config: ConfigType) -> None: + """Import yaml if we can connect. Create appropriate issue registry entries.""" + for entry in lte_config: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + if result.get("reason") == "cannot_connect": + async_create_issue( + hass, + DOMAIN, + "import_failure", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="import_failure", + ) + else: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Netgear LTE", + }, + ) - host = lte_config[CONF_HOST] - password = lte_config[CONF_PASSWORD] - websession = hass.data[DOMAIN].websession - modem = eternalegypt.Modem(hostname=host, websession=websession) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Netgear LTE from a config entry.""" + host = entry.data[CONF_HOST] + password = entry.data[CONF_PASSWORD] + if DOMAIN not in hass.data: + websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + + hass.data[DOMAIN] = LTEData(websession) + + modem = eternalegypt.Modem(hostname=host, websession=hass.data[DOMAIN].websession) modem_data = ModemData(hass, host, modem) - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) + await _login(hass, modem_data, password) - @callback - def cleanup_retry(event): - """Clean up retry task resources.""" - if not retry_task.done(): - retry_task.cancel() + async def _update(now): + """Periodic update.""" + await modem_data.async_update() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) + + async def cleanup(event: Event | None = None) -> None: + """Clean up resources.""" + update_unsub() + await modem.logout() + if DOMAIN in hass.data: + del hass.data[DOMAIN].modem_data[modem_data.host] + + entry.async_on_unload(cleanup) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) + + await async_setup_services(hass) + + _legacy_task(hass, entry) + + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + return True -async def _login(hass, modem_data, password): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: """Log in and complete setup.""" - await modem_data.modem.login(password=password) + try: + await modem_data.modem.login(password=password) + except eternalegypt.Error as ex: + raise ConfigEntryNotReady("Cannot connect/authenticate") from ex def fire_sms_event(sms): """Send an SMS event.""" @@ -237,33 +264,63 @@ async def _login(hass, modem_data, password): await modem_data.async_update() hass.data[DOMAIN].modem_data[modem_data.host] = modem_data - async def _update(now): - """Periodic update.""" - await modem_data.async_update() - update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) +def _legacy_task(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create notify service and add a repair issue when appropriate.""" + # Discovery can happen up to 2 times for notify depending on existing yaml config + # One for the name of the config entry, allows the user to customize the name + # One for each notify described in the yaml config which goes away with config flow + # One for the default if the user never specified one + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: entry.title}, + hass.data[DATA_HASS_CONFIG], + ) + ) + if not (lte_configs := hass.data[DATA_HASS_CONFIG].get(DOMAIN, [])): + return + async_create_issue( + hass, + DOMAIN, + "deprecated_notify", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify", + translation_placeholders={ + "name": f"{Platform.NOTIFY}.{entry.title.lower().replace(' ', '_')}" + }, + ) - async def cleanup(event): - """Clean up resources.""" - update_unsub() - await modem_data.modem.logout() - del hass.data[DOMAIN].modem_data[modem_data.host] - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - -async def _retry_login(hass, modem_data, password): - """Sleep and retry setup.""" - - LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) - - modem_data.connected = False - delay = 15 - - while not modem_data.connected: - await asyncio.sleep(delay) - - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - delay = min(2 * delay, 300) + for lte_config in lte_configs: + if lte_config[CONF_HOST] == entry.data[CONF_HOST]: + if not lte_config[CONF_NOTIFY]: + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: DOMAIN}, + hass.data[DATA_HASS_CONFIG], + ) + ) + break + for notify_conf in lte_config[CONF_NOTIFY]: + discovery_info = { + CONF_HOST: lte_config[CONF_HOST], + CONF_NAME: notify_conf.get(CONF_NAME), + CONF_NOTIFY: notify_conf, + } + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + discovery_info, + hass.data[DATA_HASS_CONFIG], + ) + ) + break diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index add59096024..b8441d8fb7c 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -2,40 +2,24 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BINARY_SENSOR, DOMAIN +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import BINARY_SENSOR_CLASSES +from .sensor_types import ALL_BINARY_SENSORS, BINARY_SENSOR_CLASSES -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE binary sensor devices.""" - if discovery_info is None: - return + """Set up the Netgear LTE binary sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) - - if not modem_data or not modem_data.data: - raise PlatformNotReady - - binary_sensor_conf = discovery_info[CONF_BINARY_SENSOR] - monitored_conditions = binary_sensor_conf[CONF_MONITORED_CONDITIONS] - - binary_sensors = [] - for sensor_type in monitored_conditions: - binary_sensors.append(LTEBinarySensor(modem_data, sensor_type)) - - async_add_entities(binary_sensors) + async_add_entities( + LTEBinarySensor(modem_data, sensor) for sensor in ALL_BINARY_SENSORS + ) class LTEBinarySensor(LTEEntity, BinarySensorEntity): diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py new file mode 100644 index 00000000000..a3a56bab03b --- /dev/null +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Netgear LTE integration.""" +from __future__ import annotations + +from typing import Any + +from aiohttp.cookiejar import CookieJar +from eternalegypt import Error, Modem +from eternalegypt.eternalegypt import Information +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER + + +class NetgearLTEFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Netgear LTE.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + host = config[CONF_HOST] + password = config[CONF_PASSWORD] + self._async_abort_entries_match({CONF_HOST: host}) + try: + info = await self._async_validate_input(host, password) + except InputValidationError: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input: + host = user_input[CONF_HOST] + password = user_input[CONF_PASSWORD] + + try: + info = await self._async_validate_input(host, password) + except InputValidationError as ex: + errors["base"] = ex.base + else: + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + } + ), + user_input or {CONF_HOST: DEFAULT_HOST}, + ), + errors=errors, + ) + + async def _async_validate_input(self, host: str, password: str) -> Information: + """Validate login credentials.""" + websession = async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ) + + modem = Modem( + hostname=host, + password=password, + websession=websession, + ) + try: + await modem.login() + info = await modem.information() + except Error as ex: + raise InputValidationError("cannot_connect") from ex + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") from ex + await modem.logout() + return info + + +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str) -> None: + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py index 12c8f06b695..b47218bf4e1 100644 --- a/homeassistant/components/netgear_lte/const.py +++ b/homeassistant/components/netgear_lte/const.py @@ -14,9 +14,14 @@ CONF_BINARY_SENSOR: Final = "binary_sensor" CONF_NOTIFY: Final = "notify" CONF_SENSOR: Final = "sensor" +DATA_HASS_CONFIG = "netgear_lte_hass_config" +# https://kb.netgear.com/31160/How-do-I-change-my-4G-LTE-Modem-s-IP-address-range +DEFAULT_HOST = "192.168.5.1" DISPATCHER_NETGEAR_LTE = "netgear_lte_update" DOMAIN: Final = "netgear_lte" FAILOVER_MODES = ["auto", "wire", "mobile"] LOGGER = logging.getLogger(__package__) + +MANUFACTURER: Final = "Netgear" diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index c9a5245da41..bc103018359 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -2,6 +2,7 @@ "domain": "netgear_lte", "name": "NETGEAR LTE", "codeowners": ["@tkdrob"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index c21b56799eb..ddc5e93677c 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -38,8 +38,8 @@ class NetgearNotifyService(BaseNotificationService): if not modem_data: LOGGER.error("Modem not ready") return - - targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) + if not (targets := kwargs.get(ATTR_TARGET)): + targets = self.config[CONF_NOTIFY][CONF_RECIPIENT] if not targets: LOGGER.warning("No recipients") return diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 4ca127e5724..5632999ae96 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -2,45 +2,37 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_SENSOR, DOMAIN +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE +from .sensor_types import ( + ALL_SENSORS, + SENSOR_SMS, + SENSOR_SMS_TOTAL, + SENSOR_UNITS, + SENSOR_USAGE, +) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE sensor devices.""" - if discovery_info is None: - return - - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) - - if not modem_data or not modem_data.data: - raise PlatformNotReady - - sensor_conf = discovery_info[CONF_SENSOR] - monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] + """Set up the Netgear LTE sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) sensors: list[SensorEntity] = [] - for sensor_type in monitored_conditions: - if sensor_type == SENSOR_SMS: - sensors.append(SMSUnreadSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_SMS_TOTAL: - sensors.append(SMSTotalSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_USAGE: - sensors.append(UsageSensor(modem_data, sensor_type)) + for sensor in ALL_SENSORS: + if sensor == SENSOR_SMS: + sensors.append(SMSUnreadSensor(modem_data, sensor)) + elif sensor == SENSOR_SMS_TOTAL: + sensors.append(SMSTotalSensor(modem_data, sensor)) + elif sensor == SENSOR_USAGE: + sensors.append(UsageSensor(modem_data, sensor)) else: - sensors.append(GenericSensor(modem_data, sensor_type)) + sensors.append(GenericSensor(modem_data, sensor)) async_add_entities(sensors) diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 1fd10282991..8992fb50670 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_notify": { + "title": "The Netgear LTE notify service is changing", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nThis created a service for a specified recipient without having to include the phone number.\n\nPlease adjust any automations or scripts you may have to use the `{name}` service and include target for specifying a recipient." + }, + "import_failure": { + "title": "The Netgear LTE integration failed to import", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nAn error occurred when trying to communicate with the device while attempting to import the configuration to the UI.\n\nPlease remove the Netgear LTE notify section from your YAML configuration and set it up in the UI instead." + } + }, "services": { "delete_sms": { "name": "Delete SMS", @@ -52,6 +80,5 @@ } } } - }, - "selector": {} + } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9274593b86f..dded0147422 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -319,6 +319,7 @@ FLOWS = { "nest", "netatmo", "netgear", + "netgear_lte", "nexia", "nextbus", "nextcloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9bd3de30b29..d80f4f18925 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3791,7 +3791,7 @@ }, "netgear_lte": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "NETGEAR LTE" } diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2a3dffbe41..9c97dce0a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -641,6 +641,9 @@ epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 +# homeassistant.components.netgear_lte +eternalegypt==0.0.16 + # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 diff --git a/tests/components/netgear_lte/__init__.py b/tests/components/netgear_lte/__init__.py new file mode 100644 index 00000000000..6661c92312e --- /dev/null +++ b/tests/components/netgear_lte/__init__.py @@ -0,0 +1 @@ +"""Tests for the Netgear LTE component.""" diff --git a/tests/components/netgear_lte/conftest.py b/tests/components/netgear_lte/conftest.py new file mode 100644 index 00000000000..e32034d660b --- /dev/null +++ b/tests/components/netgear_lte/conftest.py @@ -0,0 +1,85 @@ +"""Configure pytest for Netgear LTE tests.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +HOST = "192.168.5.1" +PASSWORD = "password" + +CONF_DATA = {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + aioclient_mock.get(f"http://{HOST}/model.json", exc=ClientError) + aioclient_mock.post(f"http://{HOST}/Forms/config", exc=ClientError) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE unknown error.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE connection.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text=load_fixture("netgear_lte/model.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/config", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/smsSendMsg", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Netgear LTE entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, data=CONF_DATA, unique_id="FFFFFFFFFFFFF", title="Netgear LM1200" + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + connection: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.fixture(name="setup_cannot_connect") +async def setup_cannot_connect( + hass: HomeAssistant, + config_entry: MockConfigEntry, + cannot_connect: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/netgear_lte/fixtures/model.json b/tests/components/netgear_lte/fixtures/model.json new file mode 100644 index 00000000000..c5f4a13f3af --- /dev/null +++ b/tests/components/netgear_lte/fixtures/model.json @@ -0,0 +1,450 @@ +{ + "custom": { "AtTcpEnable": false, "end": 0 }, + "webd": { + "adminPassword": "****************", + "ownerModeEnabled": false, + "hideAdminPassword": true, + "end": "" + }, + "lcd": { "end": "" }, + "sim": { + "pin": { "mode": "Disabled", "retry": 3, "end": "" }, + "puk": { "retry": 10 }, + "mep": {}, + "phoneNumber": "(555) 555-5555", + "iccid": "1234567890123456789", + "imsi": "123456789012345", + "SPN": "", + "status": "Ready", + "end": "" + }, + "sms": { + "ready": true, + "sendEnabled": true, + "sendSupported": true, + "alertSupported": true, + "alertEnabled": false, + "alertNumList": "", + "alertCfgList": [ + { "category": "FWUpdate", "enabled": false }, + { "category": "DataUsageWarning", "enabled": false }, + { "category": "DataUsageExceeded", "enabled": false }, + { "category": "LTEFailoverLTE", "enabled": false }, + { "category": "LTEFailoverETH", "enabled": false }, + {} + ], + "unreadMsgs": 1, + "msgCount": 1, + "msgs": [ + { + "id": "1", + "rxTime": "20/01/23 03:39:35 PM", + "text": "text", + "sender": "889", + "read": false + }, + {} + ], + "trans": [{}], + "sendMsg": [ + { + "clientId": "eternalegypt.eternalegypt", + "enc": "Gsm7Bit", + "errorCode": 0, + "msgId": 1, + "receiver": "+15555555555", + "status": "Succeeded", + "text": "test SMS from Home Assistant", + "txTime": "1367252824" + }, + {} + ], + "end": "" + }, + "session": { + "userRole": "Admin", + "lang": "en", + "secToken": "secret" + }, + "general": { + "defaultLanguage": "en", + "PRIid": "12345678", + "genericResetStatus": "NotStarted", + "manufacturer": "Netgear", + "model": "LM1200", + "HWversion": "1.0", + "FWversion": "EC25AFFDR07A09M4G", + "appVersion": "NTG9X07C_20.06.09.00", + "buildDate": "Unknown", + "BLversion": "", + "PRIversion": "04.19", + "IMEI": "123456789012345", + "SVN": "9", + "MEID": "", + "ESN": "0", + "FSN": "FFFFFFFFFFFFF", + "activated": true, + "webAppVersion": "LM1200-HDATA_03.03.103.201", + "HIDenabled": false, + "TCAaccepted": true, + "LEDenabled": true, + "showAdvHelp": true, + "keyLockState": "Unlocked", + "devTemperature": 30, + "verMajor": 1000, + "verMinor": 0, + "environment": "Application", + "currTime": 1367257216, + "timeZoneOffset": -14400, + "deviceName": "LM1200", + "useMetricSystem": true, + "factoryResetStatus": "NotStarted", + "setupCompleted": true, + "languageSelected": false, + "systemAlertList": { "list": [{}], "count": 0 }, + "apiVersion": "2.0", + "companyName": "NETGEAR", + "configURL": "/Forms/config", + "profileURL": "/Forms/profile", + "pinChangeURL": "/Forms/pinChange", + "portCfgURL": "/Forms/portCfg", + "portFilterURL": "/Forms/portFilter", + "wifiACLURL": "/Forms/wifiACL", + "supportedLangList": [ + { + "id": "en", + "isCurrent": "true", + "isDefault": "true", + "label": "English", + "token1": "/romfs/lcd/en_us.tr", + "token2": "" + }, + { + "id": "de_DE", + "isCurrent": "false", + "isDefault": "false", + "label": "Deutsch (German)", + "token1": "/romfs/lcd/de_de.tr", + "token2": "" + }, + { + "id": "ar_AR", + "isCurrent": "false", + "isDefault": "false", + "label": "العربية (Arabic)", + "token1": "/romfs/lcd/ar_AR.tr", + "token2": "" + }, + { + "id": "es_ES", + "isCurrent": "false", + "isDefault": "false", + "label": "Español (Spanish)", + "token1": "/romfs/lcd/es_es.tr", + "token2": "" + }, + { + "id": "fr_FR", + "isCurrent": "false", + "isDefault": "false", + "label": "Français (French)", + "token1": "/romfs/lcd/fr_fr.tr", + "token2": "" + }, + { + "id": "it_IT", + "isCurrent": "false", + "isDefault": "false", + "label": "Italiano (Italian)", + "token1": "/romfs/lcd/it_it.tr", + "token2": "" + }, + { + "id": "pl_PL", + "isCurrent": "false", + "isDefault": "false", + "label": "Polski (Polish)", + "token1": "/romfs/lcd/pl_pl.tr", + "token2": "" + }, + { + "id": "fi_FI", + "isCurrent": "false", + "isDefault": "false", + "label": "Suomi (Finnish)", + "token1": "/romfs/lcd/fi_fi.tr", + "token2": "" + }, + { + "id": "sv_SE", + "isCurrent": "false", + "isDefault": "false", + "label": "Svenska (Swedish)", + "token1": "/romfs/lcd/sv_se.tr", + "token2": "" + }, + { + "id": "tu_TU", + "isCurrent": "false", + "isDefault": "false", + "label": "Türkçe (Turkish)", + "token1": "/romfs/lcd/tu_tu.tr", + "token2": "" + }, + {} + ] + }, + "power": { + "PMState": "Init", + "SmState": "Online", + "autoOff": { + "onUSBdisconnect": { "enable": false, "countdownTimer": 0, "end": "" }, + "onIdle": { "timer": { "onAC": 0, "onBattery": 0, "end": "" } } + }, + "standby": { + "onIdle": { + "timer": { "onAC": 0, "onBattery": 600, "onUSB": 0, "end": "" } + } + }, + "autoOn": { "enable": true, "end": "" }, + "buttonHoldTime": 3, + "deviceTempCritical": false, + "resetreason": 16, + "resetRequired": "NoResetRequired", + "lpm": false, + "end": "" + }, + "wwan": { + "netScanStatus": "NotStarted", + "inactivityCause": 307, + "currentNWserviceType": "LteService", + "registerRejectCode": 0, + "netSelEnabled": "Enabled", + "netRegMode": "Auto", + "IPv6": "1234:abcd::1234:abcd", + "roaming": false, + "IP": "10.0.0.5", + "registerNetworkDisplay": "T-Mobile", + "RAT": "Only4G", + "bandRegion": [ + { "index": 0, "name": "Auto", "current": false }, + { "index": 1, "name": "LTE Only", "current": true }, + { "index": 2, "name": "WCDMA Only", "current": false }, + {} + ], + "autoconnect": "HomeNetwork", + "profileList": [ + { + "index": 1, + "id": "T-Mobile 9", + "name": "T Mobile", + "apn": "fast.t-mobile.com", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + { + "index": 2, + "id": "Mint", + "name": "Mint", + "apn": "wholesale", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + {} + ], + "profile": { + "default": "T-Mobile 9", + "defaultLTE": "T-Mobile 9", + "full": false, + "promptForApnSelection": false, + "end": "" + }, + "dataUsage": { + "total": { + "lteBillingTx": 0, + "lteBillingRx": 0, + "cdmaBillingTx": 0, + "cdmaBillingRx": 0, + "gwBillingTx": 0, + "gwBillingRx": 0, + "lteLifeTx": 0, + "lteLifeRx": 0, + "cdmaLifeTx": 0, + "cdmaLifeRx": 0, + "gwLifeTx": 0, + "gwLifeRx": 0, + "end": "" + }, + "server": { "accountType": "", "subAccountType": "", "end": "" }, + "serverDataRemaining": 0, + "serverDataTransferred": 0, + "serverDataTransferredIntl": 0, + "serverDataValidState": "Invalid", + "serverDaysLeft": 0, + "serverErrorCode": "", + "serverLowBalance": false, + "serverMsisdn": "", + "serverRechargeUrl": "", + "dataWarnEnable": true, + "prepaidAccountState": "Hot", + "accountType": "Unknown", + "share": { + "enabled": false, + "dataTransferredOthers": 0, + "lastSync": "0", + "end": "" + }, + "generic": { + "dataLimitValid": false, + "usageHighWarning": 80, + "lastSucceeded": "0", + "billingDay": 1, + "nextBillingDate": "1369627200", + "lastSync": "0", + "billingCycleRemainder": 27, + "billingCycleLimit": 0, + "dataTransferred": 42484315, + "dataTransferredRoaming": 0, + "lastReset": "1366948800", + "end": "" + } + }, + "netManualNoCvg": false, + "connection": "Connected", + "connectionType": "IPv4AndIPv6", + "currentPSserviceType": "LTE", + "ca": { "end": "" }, + "connectionText": "4G", + "sessDuration": 4282, + "sessStartTime": 1367252934, + "dataTransferred": { "totalb": "345036", "rxb": "184700", "txb": "160336" }, + "signalStrength": { + "rssi": 0, + "rscp": 0, + "ecio": 0, + "rsrp": -113, + "rsrq": -20, + "bars": 2, + "sinr": 0, + "end": "" + } + }, + "wwanadv": { + "curBand": "LTE B4", + "radioQuality": 52, + "country": "USA", + "RAC": 0, + "LAC": 12345, + "MCC": "123", + "MNC": "456", + "MNCFmt": 3, + "cellId": 12345678, + "chanId": 2300, + "primScode": -1, + "plmnSrvErrBitMask": 0, + "chanIdUl": 20300, + "txLevel": 4, + "rxLevel": -113, + "end": "" + }, + "ethernet": { + "offload": { "ipv4Addr": "0.0.0.0", "ipv6Addr": "", "end": "" } + }, + "wifi": { + "enabled": true, + "maxClientSupported": 0, + "maxClientLimit": 0, + "maxClientCnt": 0, + "channel": 0, + "hiddenSSID": true, + "passPhrase": "", + "RTSthreshold": 0, + "fragThreshold": 0, + "SSID": "", + "clientCount": 0, + "country": "", + "wps": { "supported": "Disabled", "end": "" }, + "guest": { + "maxClientCnt": 0, + "enabled": false, + "SSID": "", + "passPhrase": "", + "generatePassphrase": false, + "hiddenSSID": true, + "chan": 0, + "DHCP": { "range": { "end": "" } } + }, + "offload": { "end": "" }, + "end": "" + }, + "router": { + "gatewayIP": "192.168.5.1", + "DMZaddress": "192.168.5.4", + "DMZenabled": false, + "forceSetup": false, + "DHCP": { + "serverEnabled": true, + "DNS1": "1.1.1.1", + "DNS2": "1.1.2.2", + "DNSmode": "Auto", + "USBpcIP": "0.0.0.0", + "leaseTime": 43200, + "range": { "high": "192.168.5.99", "low": "192.168.5.20", "end": "" } + }, + "usbMode": "None", + "usbNetworkTethering": true, + "portFwdEnabled": false, + "portFwdList": [{}], + "portFilteringEnabled": false, + "portFilteringMode": "None", + "portFilterWhiteList": [{}], + "portFilterBlackList": [{}], + "hostName": "routerlogin", + "domainName": "net", + "ipPassThroughEnabled": false, + "ipPassThroughSupported": true, + "Ipv6Supported": true, + "UPNPsupported": false, + "UPNPenabled": false, + "clientList": { "list": [{}], "count": 0 }, + "end": "" + }, + "fota": { + "fwupdater": { + "available": false, + "chkallow": true, + "chkstatus": "Initial", + "dloadProg": 0, + "error": false, + "lastChkDate": 1367200419, + "state": "NoNewFw", + "isPostponable": false, + "statusCode": 200, + "chkTimeLeft": 0, + "dloadSize": 0, + "end": "" + } + }, + "failover": { + "mode": "Auto", + "backhaul": "LTE", + "supported": true, + "monitorPeriod": 10, + "wanConnected": false, + "keepaliveEnable": false, + "keepaliveSleep": 15, + "ipv4Targets": [{ "id": "0", "string": "8.8.8.8" }, {}], + "ipv6Targets": [{}], + "end": "" + }, + "eventlog": { "level": 0, "end": 0 }, + "ui": { "serverDaysLeftHide": false, "promptActivation": true, "end": 0 } +} diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py new file mode 100644 index 00000000000..8ed43c8c887 --- /dev/null +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -0,0 +1,19 @@ +"""The tests for Netgear LTE binary sensor platform.""" +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Test for successfully setting up the Netgear LTE binary sensor platform.""" + state = hass.states.get("binary_sensor.netgear_lte_mobile_connected") + assert state.state == STATE_ON + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY + state = hass.states.get("binary_sensor.netgear_lte_wire_connected") + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY + state = hass.states.get("binary_sensor.netgear_lte_roaming") + assert state.state == STATE_OFF diff --git a/tests/components/netgear_lte/test_config_flow.py b/tests/components/netgear_lte/test_config_flow.py new file mode 100644 index 00000000000..97a624a14e7 --- /dev/null +++ b/tests/components/netgear_lte/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test Netgear LTE config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA + + +def _patch_setup(): + return patch( + "homeassistant.components.netgear_lte.async_setup_entry", return_value=True + ) + + +async def test_flow_user_form(hass: HomeAssistant, connection: None) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with _patch_setup(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + assert result["context"]["unique_id"] == "FFFFFFFFFFFFF" + + +@pytest.mark.parametrize("source", (SOURCE_USER, SOURCE_IMPORT)) +async def test_flow_already_configured( + hass: HomeAssistant, setup_integration: None, source: str +) -> None: + """Test config flow aborts when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: source}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, cannot_connect: None +) -> None: + """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None: + """Test unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + +async def test_flow_import(hass: HomeAssistant, connection: None) -> None: + """Test import step.""" + with _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + + +async def test_flow_import_failure(hass: HomeAssistant, cannot_connect: None) -> None: + """Test import step failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py new file mode 100644 index 00000000000..7c48d9d87d2 --- /dev/null +++ b/tests/components/netgear_lte/test_init.py @@ -0,0 +1,28 @@ +"""Test Netgear LTE integration.""" +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA + + +async def test_setup_unload(hass: HomeAssistant, setup_integration: None) -> None: + """Test setup and unload.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.data == CONF_DATA + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_cannot_connect: None +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/netgear_lte/test_notify.py b/tests/components/netgear_lte/test_notify.py new file mode 100644 index 00000000000..12d906138c3 --- /dev/null +++ b/tests/components/netgear_lte/test_notify.py @@ -0,0 +1,29 @@ +"""The tests for the Netgear LTE notify platform.""" +from unittest.mock import patch + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TARGET, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant + +ICON_PATH = "/some/path" +MESSAGE = "one, two, testing, testing" + + +async def test_notify(hass: HomeAssistant, setup_integration: None) -> None: + """Test sending a message.""" + assert hass.services.has_service(NOTIFY_DOMAIN, "netgear_lm1200") + + with patch("homeassistant.components.netgear_lte.eternalegypt.Modem.sms") as mock: + await hass.services.async_call( + NOTIFY_DOMAIN, + "netgear_lm1200", + { + ATTR_MESSAGE: MESSAGE, + ATTR_TARGET: "5555555556", + }, + blocking=True, + ) + assert len(mock.mock_calls) == 1 diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py new file mode 100644 index 00000000000..8682af9a5c3 --- /dev/null +++ b/tests/components/netgear_lte/test_sensor.py @@ -0,0 +1,56 @@ +"""The tests for Netgear LTE sensor platform.""" +import pytest + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfInformation, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") +async def test_sensors(hass: HomeAssistant) -> None: + """Test for successfully setting up the Netgear LTE sensor platform.""" + state = hass.states.get("sensor.netgear_lte_cell_id") + assert state.state == "12345678" + state = hass.states.get("sensor.netgear_lte_connection_text") + assert state.state == "4G" + state = hass.states.get("sensor.netgear_lte_connection_type") + assert state.state == "IPv4AndIPv6" + state = hass.states.get("sensor.netgear_lte_current_band") + assert state.state == "LTE B4" + state = hass.states.get("sensor.netgear_lte_current_ps_service_type") + assert state.state == "LTE" + state = hass.states.get("sensor.netgear_lte_radio_quality") + assert state.state == "52" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + state = hass.states.get("sensor.netgear_lte_register_network_display") + assert state.state == "T-Mobile" + state = hass.states.get("sensor.netgear_lte_rx_level") + assert state.state == "-113" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + state = hass.states.get("sensor.netgear_lte_sms") + assert state.state == "1" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "unread" + state = hass.states.get("sensor.netgear_lte_sms_total") + assert state.state == "1" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "messages" + state = hass.states.get("sensor.netgear_lte_tx_level") + assert state.state == "4" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + state = hass.states.get("sensor.netgear_lte_upstream") + assert state.state == "LTE" + state = hass.states.get("sensor.netgear_lte_usage") + assert state.state == "40.5" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.MEBIBYTES + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE diff --git a/tests/components/netgear_lte/test_services.py b/tests/components/netgear_lte/test_services.py new file mode 100644 index 00000000000..5c5c33be980 --- /dev/null +++ b/tests/components/netgear_lte/test_services.py @@ -0,0 +1,55 @@ +"""Services tests for the Netgear LTE integration.""" +from unittest.mock import patch + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .conftest import HOST + + +async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None: + """Test service call set option.""" + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.set_failover_mode" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "set_option", + {CONF_HOST: HOST, "failover": "auto", "autoconnect": "home"}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.connect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "connect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.disconnect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "disconnect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.delete_sms" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "delete_sms", + {CONF_HOST: HOST, "sms_id": 1}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 From 18ace167448f832adbcf22132b9fc082c0ec9f45 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 25 Dec 2023 23:29:05 -0500 Subject: [PATCH 721/927] Bump zwave-js-server-python to 0.55.1 (#105502) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f2d32d499c9..62e1ecfaf08 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.54.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cc73ef173ed..dce1f3980b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2888,7 +2888,7 @@ zigpy==0.60.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.54.0 +zwave-js-server-python==0.55.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c97dce0a8b..a474821a2ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2183,7 +2183,7 @@ zigpy-znp==0.12.1 zigpy==0.60.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.54.0 +zwave-js-server-python==0.55.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From b800475242a0a2e6208102f6c9242069b2592196 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Dec 2023 20:28:40 -1000 Subject: [PATCH 722/927] Use shorthand attrs for more tplink light attributes (#106399) * Use shorthand attrs for more tplink light attributes supported_color_modes and features were having to be recalced every time state was written * preen --- homeassistant/components/tplink/light.py | 32 ++++++++---------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index db7e6ff355e..8e77c68a880 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -182,6 +182,16 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): self._attr_unique_id = legacy_device_id(device) else: self._attr_unique_id = device.mac.replace(":", "").upper() + modes: set[ColorMode] = set() + if device.is_variable_color_temp: + modes.add(ColorMode.COLOR_TEMP) + if device.is_color: + modes.add(ColorMode.HS) + if device.is_dimmable: + modes.add(ColorMode.BRIGHTNESS) + if not modes: + modes.add(ColorMode.ONOFF) + self._attr_supported_color_modes = modes @callback def _async_extract_brightness_transition( @@ -267,22 +277,6 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): hue, saturation, _ = self.device.hsv return hue, saturation - @property - def supported_color_modes(self) -> set[ColorMode]: - """Return list of available color modes.""" - modes: set[ColorMode] = set() - if self.device.is_variable_color_temp: - modes.add(ColorMode.COLOR_TEMP) - if self.device.is_color: - modes.add(ColorMode.HS) - if self.device.is_dimmable: - modes.add(ColorMode.BRIGHTNESS) - - if not modes: - modes.add(ColorMode.ONOFF) - - return modes - @property def color_mode(self) -> ColorMode: """Return the active color mode.""" @@ -300,11 +294,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Representation of a TPLink Smart Light Strip.""" device: SmartLightStrip - - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - return super().supported_features | LightEntityFeature.EFFECT + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @property def effect_list(self) -> list[str] | None: From 3eef1a3f6ad68c52964f13df79af5cc60bb4ba6d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 26 Dec 2023 07:56:21 +0100 Subject: [PATCH 723/927] Add valve platform for Shelly Gas Valve (#106087) * Add valve platform * Update BLOCK_PLATFORMS * Add tests * Use _attr_is_closed * Fix mypy errors * Make the valve switch to disabled by default * Add deprecation issues * Remove LOGGER * Clean * Add deprecation comments * Suggested changes * Set True for is_fixable * Show valve entity and services in repair issue --- homeassistant/components/shelly/__init__.py | 1 + homeassistant/components/shelly/strings.json | 8 ++ homeassistant/components/shelly/switch.py | 66 +++++++++- homeassistant/components/shelly/valve.py | 122 +++++++++++++++++++ tests/components/shelly/test_switch.py | 72 ++++++++++- tests/components/shelly/test_valve.py | 72 +++++++++++ 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/shelly/valve.py create mode 100644 tests/components/shelly/test_valve.py diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 0fab86f7f4f..6b7a00db8e2 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -63,6 +63,7 @@ BLOCK_PLATFORMS: Final = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VALVE, ] BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 330dd246c47..c1f9b799444 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -160,6 +160,14 @@ "push_update_failure": { "title": "Shelly device {device_name} push update failure", "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration." + }, + "deprecated_valve_switch": { + "title": "The switch entity for Shelly Gas Valve is deprecated", + "description": "The switch entity for Shelly Gas Valve is deprecated. A valve entity {entity} is available and should be used going forward. For this new valve entity you need to use {service} service." + }, + "deprecated_valve_switch_entity": { + "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", + "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 98811c2ff6f..e5d91943a55 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,12 +7,20 @@ from typing import Any, cast from aioshelly.block_device import Block from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY +from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -35,11 +43,13 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" +# This entity description is deprecated and will be removed in Home Assistant 2024.7.0. GAS_VALVE_SWITCH = BlockSwitchDescription( key="valve|valve", name="Valve", available=lambda block: block.valve not in ("failure", "checking"), removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), + entity_registry_enabled_default=False, ) @@ -137,7 +147,10 @@ def async_setup_rpc_entry( class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): - """Entity that controls a Gas Valve on Block based Shelly devices.""" + """Entity that controls a Gas Valve on Block based Shelly devices. + + This class is deprecated and will be removed in Home Assistant 2024.7.0. + """ entity_description: BlockSwitchDescription @@ -167,14 +180,61 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Open valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.open_valve", + }, + ) self.control_result = await self.set_state(go="open") self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Close valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switche", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.close_valve", + }, + ) self.control_result = await self.set_state(go="close") self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + await super().async_added_to_hass() + + entity_automations = automations_with_entity(self.hass, self.entity_id) + entity_scripts = scripts_with_entity(self.hass, self.entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_valve_{self.entity_id}_{item}", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) + @callback def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py new file mode 100644 index 00000000000..7bc4a9a5329 --- /dev/null +++ b/homeassistant/components/shelly/valve.py @@ -0,0 +1,122 @@ +"""Valve for Shelly.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from aioshelly.block_device import Block +from aioshelly.const import BLOCK_GENERATIONS, MODEL_GAS + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ShellyBlockCoordinator, get_entry_data +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + async_setup_block_attribute_entities, +) +from .utils import get_device_entry_gen + + +@dataclass(kw_only=True, frozen=True) +class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): + """Class to describe a BLOCK valve.""" + + +GAS_VALVE = BlockValveDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valves for device.""" + if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: + async_setup_block_entry(hass, config_entry, async_add_entities) + + +@callback +def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valve for device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator and coordinator.device.blocks + + if coordinator.model == MODEL_GAS: + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE}, + BlockShellyValve, + ) + + +class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity): + """Entity that controls a valve on block based Shelly devices.""" + + entity_description: BlockValveDescription + _attr_device_class = ValveDeviceClass.GAS + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockValveDescription, + ) -> None: + """Initialize block valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + self._attr_is_closed = bool(self.attribute_value == "closed") + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "closing") + + return self.attribute_value == "closing" + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "opening") + + return self.attribute_value == "opening" + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self._attr_is_closed = bool(self.attribute_value == "closed") + super()._update_callback() diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index e19416706e1..9a99116e66c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -6,7 +6,10 @@ from aioshelly.const import MODEL_GAS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -21,6 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from . import init_integration, register_entity @@ -238,9 +243,14 @@ async def test_block_device_gas_valve( hass: HomeAssistant, mock_block_device, monkeypatch ) -> None: """Test block device Shelly Gas with Valve addon.""" + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) registry = er.async_get(hass) await init_integration(hass, 1, MODEL_GAS) - entity_id = "switch.test_name_valve" entry = registry.async_get(entity_id) assert entry @@ -316,3 +326,63 @@ async def test_wall_display_relay_mode( # the climate entity should be removed assert hass.states.get(entity_id) is None + + +async def test_create_issue_valve_switch( + hass: HomeAssistant, + mock_block_device, + entity_registry_enabled_by_default: None, + monkeypatch, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": {"service": "switch.turn_on", "entity_id": entity_id}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": entity_id}, + }, + ], + } + } + }, + ) + + await init_integration(hass, 1, MODEL_GAS) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_valve_switch") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_script.test" + ) + + assert len(issue_registry.issues) == 3 diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py new file mode 100644 index 00000000000..0db79b63a9d --- /dev/null +++ b/tests/components/shelly/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for Shelly valve platform.""" +from aioshelly.const import MODEL_GAS + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +GAS_VALVE_BLOCK_ID = 6 + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, MODEL_GAS) + entity_id = "valve.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED From b17bec2b0aadd733a87af4efc7161c1444ff15d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Dec 2023 21:03:35 -1000 Subject: [PATCH 724/927] Cache media player url hashing (#106400) --- homeassistant/components/media_player/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 706539664ec..977c7cbf0f3 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -8,6 +8,7 @@ from contextlib import suppress import datetime as dt from enum import StrEnum import functools as ft +from functools import lru_cache import hashlib from http import HTTPStatus import logging @@ -496,6 +497,12 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@lru_cache +def _url_hash(url: str) -> str: + """Create hash for media image url.""" + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + + class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for media player entities.""" @@ -636,7 +643,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return self._attr_media_image_hash if (url := self.media_image_url) is not None: - return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + return _url_hash(url) return None From 1c96cf33b894f1d6ba5d5a65405d2a49946028fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Dec 2023 21:11:48 -1000 Subject: [PATCH 725/927] Avoid recreating a set each time sensor unit_of_measurement is called (#106401) --- homeassistant/components/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9965fae9d59..5fca119d5b5 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -198,6 +198,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "suggested_unit_of_measurement", } +TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" @@ -509,8 +511,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): native_unit_of_measurement = self.native_unit_of_measurement if ( - native_unit_of_measurement - in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + native_unit_of_measurement in TEMPERATURE_UNITS and self.device_class is SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit From 0d2ec6cd5c6460a980dc962b69e670bc8bdaac27 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 26 Dec 2023 13:11:05 +0100 Subject: [PATCH 726/927] Improve drop_connect typing (#106404) --- homeassistant/components/drop_connect/binary_sensor.py | 2 +- homeassistant/components/drop_connect/coordinator.py | 2 +- homeassistant/components/drop_connect/select.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index 4c392eb8ce1..1bce60f87b3 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -49,7 +49,7 @@ SALT_LOW = "salt" class DROPBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes DROP binary sensor entity.""" - value_fn: Callable[[DROPDeviceDataUpdateCoordinator], bool | None] + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index 67409528402..e4937ed5f65 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -15,7 +15,7 @@ from .const import CONF_COMMAND_TOPIC, DOMAIN _LOGGER = logging.getLogger(__name__) -class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator): +class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """DROP device object.""" config_entry: ConfigEntry diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index 365345e147d..e026cfcd59e 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -30,7 +30,7 @@ FLOOD_ICON = "mdi:home-flood" class DROPSelectEntityDescription(SelectEntityDescription): """Describes DROP select entity.""" - value_fn: Callable[[DROPDeviceDataUpdateCoordinator], str | None] + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] set_fn: Callable[[DROPDeviceDataUpdateCoordinator, str], Awaitable[Any]] @@ -88,7 +88,8 @@ class DROPSelect(DROPEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the current selected option.""" - return self.entity_description.value_fn(self.coordinator) + val = self.entity_description.value_fn(self.coordinator) + return str(val) if val else None async def async_select_option(self, option: str) -> None: """Update the current selected option.""" From c8f9285abae1ecb4a7882da43e47d2aa9d4adbd5 Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Tue, 26 Dec 2023 13:22:53 +0100 Subject: [PATCH 727/927] Switch unifi_direct to external library (#105046) * switch to external library Signed-off-by: Tobias Perschon * use mac as name if no hostname is available Signed-off-by: Tobias Perschon * update requirements_test_all Signed-off-by: Tobias Perschon * update .coveragerc Signed-off-by: Tobias Perschon * update codeowners and remove old tests Signed-off-by: Tobias Perschon * reverted get_device_name to old behaviour Signed-off-by: Tobias Perschon * typing and some cleanup Signed-off-by: Tobias Perschon * typing fix Signed-off-by: Tobias Perschon * code cleanup Signed-off-by: Tobias Perschon --------- Signed-off-by: Tobias Perschon --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/unifi_direct/device_tracker.py | 126 +++---------- .../components/unifi_direct/manifest.json | 6 +- requirements_all.txt | 4 +- requirements_test_all.txt | 6 - tests/components/unifi_direct/__init__.py | 1 - .../components/unifi_direct/fixtures/data.txt | 1 - .../unifi_direct/test_device_tracker.py | 178 ------------------ 9 files changed, 40 insertions(+), 285 deletions(-) delete mode 100644 tests/components/unifi_direct/__init__.py delete mode 100644 tests/components/unifi_direct/fixtures/data.txt delete mode 100644 tests/components/unifi_direct/test_device_tracker.py diff --git a/.coveragerc b/.coveragerc index 2e5748d2a98..cdc148e5f5c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1427,6 +1427,8 @@ omit = homeassistant/components/ukraine_alarm/__init__.py homeassistant/components/ukraine_alarm/binary_sensor.py homeassistant/components/unifiled/* + homeassistant/components/unifi_direct/__init__.py + homeassistant/components/unifi_direct/device_tracker.py homeassistant/components/upb/__init__.py homeassistant/components/upb/light.py homeassistant/components/upc_connect/* diff --git a/CODEOWNERS b/CODEOWNERS index fda7f27c412..b3d5475379e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1389,6 +1389,7 @@ build.json @home-assistant/supervisor /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @AngellusMortis @bdraco /tests/components/unifiprotect/ @AngellusMortis @bdraco diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 13ebd0e33e5..77ce5d80cf9 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -1,10 +1,10 @@ """Support for Unifi AP direct access.""" from __future__ import annotations -import json import logging +from typing import Any -from pexpect import exceptions, pxssh +from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -20,9 +20,6 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) DEFAULT_SSH_PORT = 22 -UNIFI_COMMAND = 'mca-dump | tr -d "\n"' -UNIFI_SSID_TABLE = "vap_table" -UNIFI_CLIENT_TABLE = "sta_table" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -37,104 +34,43 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | None: """Validate the configuration and return a Unifi direct scanner.""" scanner = UnifiDeviceScanner(config[DOMAIN]) - if not scanner.connected: - return None - return scanner + return scanner if scanner.update_clients() else None class UnifiDeviceScanner(DeviceScanner): """Class which queries Unifi wireless access point.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.port = config[CONF_PORT] - self.ssh = None - self.connected = False - self.last_results = {} - self._connect() - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - result = _response_to_json(self._get_update()) - if result: - self.last_results = result - return self.last_results.keys() - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - hostname = next( - ( - value.get("hostname") - for key, value in self.last_results.items() - if key.upper() == device.upper() - ), - None, + self.clients: dict[str, dict[str, Any]] = {} + self.ap = UniFiAP( + target=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], ) - if hostname is not None: - hostname = str(hostname) - return hostname - def _connect(self): - """Connect to the Unifi AP SSH server.""" + def scan_devices(self) -> list[str]: + """Scan for new devices and return a list with found device IDs.""" + self.update_clients() + return list(self.clients) - self.ssh = pxssh.pxssh(options={"HostKeyAlgorithms": "ssh-rsa"}) + def get_device_name(self, device: str) -> str | None: + """Return the name of the given device or None if we don't know.""" + client_info = self.clients.get(device) + if client_info: + return client_info.get("hostname") + return None + + def update_clients(self) -> bool: + """Update the client info from AP.""" try: - self.ssh.login( - self.host, self.username, password=self.password, port=self.port - ) - self.connected = True - except exceptions.EOF: - _LOGGER.error("Connection refused. SSH enabled?") - self._disconnect() + self.clients = self.ap.get_clients() + except UniFiAPConnectionException: + _LOGGER.error("Failed to connect to accesspoint") + return False + except UniFiAPDataException: + _LOGGER.error("Failed to get proper response from accesspoint") + return False - def _disconnect(self): - """Disconnect the current SSH connection.""" - try: - self.ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self.ssh = None - - self.connected = False - - def _get_update(self): - try: - if not self.connected: - self._connect() - # If we still aren't connected at this point - # don't try to send anything to the AP. - if not self.connected: - return None - self.ssh.sendline(UNIFI_COMMAND) - self.ssh.prompt() - return self.ssh.before - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) - self._disconnect() - return None - except (AssertionError, exceptions.EOF) as err: - _LOGGER.error("Connection to AP unavailable: %s", str(err)) - self._disconnect() - return None - - -def _response_to_json(response): - try: - json_response = json.loads(str(response)[31:-1].replace("\\", "")) - _LOGGER.debug(str(json_response)) - ssid_table = json_response.get(UNIFI_SSID_TABLE) - active_clients = {} - - for ssid in ssid_table: - client_table = ssid.get(UNIFI_CLIENT_TABLE) - for client in client_table: - active_clients[client.get("mac")] = client - - return active_clients - except (ValueError, TypeError): - _LOGGER.error("Failed to decode response from AP") - return {} + return True diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 68a1396727f..8ca8ef27bb2 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -1,9 +1,9 @@ { "domain": "unifi_direct", "name": "UniFi AP", - "codeowners": [], + "codeowners": ["@tofuSCHNITZEL"], "documentation": "https://www.home-assistant.io/integrations/unifi_direct", "iot_class": "local_polling", - "loggers": ["pexpect", "ptyprocess"], - "requirements": ["pexpect==4.6.0"] + "loggers": ["unifi_ap"], + "requirements": ["unifi_ap==0.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dce1f3980b4..851e9c2f6e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1478,7 +1478,6 @@ pescea==1.0.12 # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora -# homeassistant.components.unifi_direct pexpect==4.6.0 # homeassistant.components.modem_callerid @@ -2698,6 +2697,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 +# homeassistant.components.unifi_direct +unifi_ap==0.0.1 + # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a474821a2ac..a76da0a37d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1142,12 +1142,6 @@ peco==0.0.29 # homeassistant.components.escea pescea==1.0.12 -# homeassistant.components.aruba -# homeassistant.components.cisco_ios -# homeassistant.components.pandora -# homeassistant.components.unifi_direct -pexpect==4.6.0 - # homeassistant.components.modem_callerid phone-modem==0.1.1 diff --git a/tests/components/unifi_direct/__init__.py b/tests/components/unifi_direct/__init__.py deleted file mode 100644 index 7f8d0fa29f7..00000000000 --- a/tests/components/unifi_direct/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the unifi_direct component.""" diff --git a/tests/components/unifi_direct/fixtures/data.txt b/tests/components/unifi_direct/fixtures/data.txt deleted file mode 100644 index fcb58070fcc..00000000000 --- a/tests/components/unifi_direct/fixtures/data.txt +++ /dev/null @@ -1 +0,0 @@ -b'mca-dump | tr -d "\r\n> "\r\n{ "board_rev": 16, "bootrom_version": "unifi-v1.6.7.249-gb74e0282", "cfgversion": "63b505a1c328fd9c", "country_code": 840, "default": false, "discovery_response": true, "fw_caps": 855, "guest_token": "E6BAE04FD72C", "has_eth1": false, "has_speaker": false, "hostname": "UBNT", "if_table": [ { "full_duplex": true, "ip": "0.0.0.0", "mac": "80:2a:a8:56:34:12", "name": "eth0", "netmask": "0.0.0.0", "num_port": 1, "rx_bytes": 3879332085, "rx_dropped": 0, "rx_errors": 0, "rx_multicast": 0, "rx_packets": 4093520, "speed": 1000, "tx_bytes": 1745140940, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 3105586, "up": true } ], "inform_url": "?", "ip": "192.168.1.2", "isolated": false, "last_error": "", "locating": false, "mac": "80:2a:a8:56:34:12", "model": "U7LR", "model_display": "UAP-AC-LR", "netmask": "255.255.255.0", "port_table": [ { "media": "GE", "poe_caps": 0, "port_idx": 0, "port_poe": false } ], "radio_table": [ { "athstats": { "ast_ath_reset": 0, "ast_be_xmit": 1098121, "ast_cst": 225, "ast_deadqueue_reset": 0, "ast_fullqueue_stop": 0, "ast_txto": 151, "cu_self_rx": 8, "cu_self_tx": 4, "cu_total": 12, "n_rx_aggr": 3915695, "n_rx_pkts": 6518082, "n_tx_bawadv": 1205430, "n_tx_bawretries": 70257, "n_tx_pkts": 1813368, "n_tx_queue": 1024366, "n_tx_retries": 70273, "n_tx_xretries": 897, "n_txaggr_compgood": 616173, "n_txaggr_compretries": 71170, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 21240, "name": "wifi0" }, "builtin_ant_gain": 0, "builtin_antenna": true, "max_txpower": 24, "min_txpower": 6, "name": "wifi0", "nss": 3, "radio": "ng", "scan_table": [ { "age": 2, "bssid": "28:56:5a:34:23:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "someones_wifi", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 8, "rssi_age": 2, "security": "secured" }, { "age": 37, "bssid": "00:60:0f:45:34:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 37, "security": "secured" }, { "age": 29, "bssid": "b0:93:5b:7a:35:23", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "ARRIS-CB55", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 29, "security": "secured" }, { "age": 0, "bssid": "e0:46:9a:e1:ea:7d", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Darjeeling", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 9, "rssi_age": 0, "security": "secured" }, { "age": 1, "bssid": "00:60:0f:e1:ea:7e", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 1, "security": "secured" }, { "age": 0, "bssid": "7c:d1:c3:cd:e5:f4", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Chris\'s Wi-Fi Network", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 17, "rssi_age": 0, "security": "secured" } ] }, { "athstats": { "ast_ath_reset": 14, "ast_be_xmit": 1097310, "ast_cst": 0, "ast_deadqueue_reset": 41, "ast_fullqueue_stop": 0, "ast_txto": 0, "cu_self_rx": 0, "cu_self_tx": 0, "cu_total": 0, "n_rx_aggr": 106804, "n_rx_pkts": 2453041, "n_tx_bawadv": 557298, "n_tx_bawretries": 0, "n_tx_pkts": 1080, "n_tx_queue": 0, "n_tx_retries": 1, "n_tx_xretries": 44046, "n_txaggr_compgood": 0, "n_txaggr_compretries": 0, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 0, "name": "wifi1" }, "builtin_ant_gain": 0, "builtin_antenna": true, "has_dfs": true, "has_fccdfs": true, "is_11ac": true, "max_txpower": 22, "min_txpower": 4, "name": "wifi1", "nss": 2, "radio": "na", "scan_table": [] } ], "required_version": "3.4.1", "selfrun_beacon": false, "serial": "802AA896363C", "spectrum_scanning": false, "ssh_session_table": [], "state": 0, "stream_token": "", "sys_stats": { "loadavg_1": "0.03", "loadavg_15": "0.06", "loadavg_5": "0.06", "mem_buffer": 0, "mem_total": 129310720, "mem_used": 75800576 }, "system-stats": { "cpu": "8.4", "mem": "58.6", "uptime": "112391" }, "time": 1508795154, "uplink": "eth0", "uptime": 112391, "vap_table": [ { "bssid": "80:2a:a8:97:36:3c", "ccq": 914, "channel": 11, "essid": "220", "id": "55b19c7e50e4e11e798e84c7", "name": "ath0", "num_sta": 20, "radio": "ng", "rx_bytes": 1155345354, "rx_crypts": 5491, "rx_dropped": 5540, "rx_errors": 5540, "rx_frags": 0, "rx_nwids": 647001, "rx_packets": 1840967, "sta_table": [ { "auth_time": 4294967206, "authorized": true, "ccq": 991, "dhcpend_time": 660, "dhcpstart_time": 660, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.45", "is_11n": true, "mac": "44:65:0d:12:34:56", "noise": -114, "rssi": 59, "rx_bytes": 1176121, "rx_mcast": 0, "rx_packets": 20927, "rx_rate": 24000, "rx_retries": 0, "signal": -55, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 364495, "tx_packets": 2183, "tx_power": 48, "tx_rate": 72222, "tx_retries": 589, "uptime": 7031, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 290, "dhcpstart_time": 290, "hostname": "iPhone", "idletime": 9, "ip": "192.168.1.209", "is_11n": true, "mac": "98:00:c6:56:34:12", "noise": -114, "rssi": 40, "rx_bytes": 5862172, "rx_mcast": 0, "rx_packets": 30977, "rx_rate": 24000, "rx_retries": 0, "signal": -74, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 31707361, "tx_packets": 27775, "tx_power": 48, "tx_rate": 140637, "tx_retries": 1213, "uptime": 15556, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 630, "dhcpstart_time": 630, "hostname": "android", "idletime": 0, "ip": "192.168.1.10", "is_11n": true, "mac": "b4:79:a7:45:34:12", "noise": -114, "rssi": 60, "rx_bytes": 13694423, "rx_mcast": 0, "rx_packets": 110909, "rx_rate": 1000, "rx_retries": 0, "signal": -54, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 7988429, "tx_packets": 28863, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1254, "uptime": 19052, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 4480, "dhcpstart_time": 4480, "hostname": "wink", "idletime": 0, "ip": "192.168.1.3", "is_11n": true, "mac": "b4:79:a7:56:34:12", "noise": -114, "rssi": 38, "rx_bytes": 18705870, "rx_mcast": 0, "rx_packets": 78794, "rx_rate": 72109, "rx_retries": 0, "signal": -76, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 4416534, "tx_packets": 58304, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1978, "uptime": 51648, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 981, "dhcpend_time": 1530, "dhcpstart_time": 1530, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.30", "is_11n": true, "mac": "80:d2:1d:56:34:12", "noise": -114, "rssi": 37, "rx_bytes": 29377621, "rx_mcast": 0, "rx_packets": 105806, "rx_rate": 72109, "rx_retries": 0, "signal": -77, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 122681792, "tx_packets": 145339, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2980, "uptime": 53658, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 370, "dhcpstart_time": 360, "idletime": 2, "ip": "192.168.1.51", "is_11n": false, "mac": "48:02:2d:56:34:12", "noise": -114, "rssi": 56, "rx_bytes": 48148926, "rx_mcast": 0, "rx_packets": 59462, "rx_rate": 1000, "rx_retries": 0, "signal": -58, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 7075470, "tx_packets": 33047, "tx_power": 48, "tx_rate": 54000, "tx_retries": 2833, "uptime": 63850, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 971, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "ESP_1C2F8D", "idletime": 0, "ip": "192.168.1.54", "is_11n": true, "mac": "a0:20:a6:45:35:12", "noise": -114, "rssi": 51, "rx_bytes": 4684699, "rx_mcast": 0, "rx_packets": 137798, "rx_rate": 2000, "rx_retries": 0, "signal": -63, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 355735, "tx_packets": 6977, "tx_power": 48, "tx_rate": 72222, "tx_retries": 590, "uptime": 78427, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 220, "dhcpstart_time": 220, "hostname": "HF-LPB100-ZJ200", "idletime": 2, "ip": "192.168.1.53", "is_11n": true, "mac": "f0:fe:6b:56:34:12", "noise": -114, "rssi": 29, "rx_bytes": 1415840, "rx_mcast": 0, "rx_packets": 22821, "rx_rate": 1000, "rx_retries": 0, "signal": -85, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 402439, "tx_packets": 7779, "tx_power": 48, "tx_rate": 72222, "tx_retries": 891, "uptime": 111944, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 1620, "dhcpstart_time": 1620, "idletime": 0, "ip": "192.168.1.33", "is_11n": false, "mac": "94:10:3e:45:34:12", "noise": -114, "rssi": 48, "rx_bytes": 47843953, "rx_mcast": 0, "rx_packets": 79456, "rx_rate": 54000, "rx_retries": 0, "signal": -66, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 4357955, "tx_packets": 60958, "tx_power": 48, "tx_rate": 54000, "tx_retries": 4598, "uptime": 112316, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 540, "dhcpstart_time": 540, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.46", "is_11n": true, "mac": "ac:63:be:56:34:12", "noise": -114, "rssi": 30, "rx_bytes": 14607810, "rx_mcast": 0, "rx_packets": 326158, "rx_rate": 24000, "rx_retries": 0, "signal": -84, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 3238319, "tx_packets": 25605, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2465, "uptime": 112364, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 941, "dhcpend_time": 1060, "dhcpstart_time": 1060, "hostname": "Broadlink_RMMINI-56-34-12", "idletime": 12, "ip": "192.168.1.52", "is_11n": true, "mac": "34:ea:34:56:34:12", "noise": -114, "rssi": 43, "rx_bytes": 625268, "rx_mcast": 0, "rx_packets": 4711, "rx_rate": 65000, "rx_retries": 0, "signal": -71, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 420763, "tx_packets": 4620, "tx_power": 48, "tx_rate": 65000, "tx_retries": 783, "uptime": 112368, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 930, "dhcpend_time": 3360, "dhcpstart_time": 3360, "hostname": "garage", "idletime": 2, "ip": "192.168.1.28", "is_11n": true, "mac": "00:13:ef:45:34:12", "noise": -114, "rssi": 28, "rx_bytes": 11639474, "rx_mcast": 0, "rx_packets": 102103, "rx_rate": 24000, "rx_retries": 0, "signal": -86, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 6282728, "tx_packets": 85279, "tx_power": 48, "tx_rate": 58500, "tx_retries": 21185, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 991, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "keurig", "idletime": 0, "ip": "192.168.1.48", "is_11n": true, "mac": "18:fe:34:56:34:12", "noise": -114, "rssi": 52, "rx_bytes": 17781940, "rx_mcast": 0, "rx_packets": 432172, "rx_rate": 6000, "rx_retries": 0, "signal": -62, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 4143184, "tx_packets": 53751, "tx_power": 48, "tx_rate": 72222, "tx_retries": 3781, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 940, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "freezer", "idletime": 0, "ip": "192.168.1.26", "is_11n": true, "mac": "5c:cf:7f:07:5a:a4", "noise": -114, "rssi": 47, "rx_bytes": 13613265, "rx_mcast": 0, "rx_packets": 411785, "rx_rate": 2000, "rx_retries": 0, "signal": -67, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 1411127, "tx_packets": 17492, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5869, "uptime": 112370, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 778, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "fan", "idletime": 0, "ip": "192.168.1.34", "is_11n": true, "mac": "5c:cf:7f:02:09:4e", "noise": -114, "rssi": 45, "rx_bytes": 15377230, "rx_mcast": 0, "rx_packets": 417435, "rx_rate": 6000, "rx_retries": 0, "signal": -69, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 2974258, "tx_packets": 36175, "tx_power": 48, "tx_rate": 58500, "tx_retries": 18552, "uptime": 112372, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 1070, "dhcpstart_time": 1070, "hostname": "Broadlink_RMPROPLUS-45-34-12", "idletime": 1, "ip": "192.168.1.9", "is_11n": true, "mac": "b4:43:0d:45:56:56", "noise": -114, "rssi": 57, "rx_bytes": 1792908, "rx_mcast": 0, "rx_packets": 8528, "rx_rate": 72109, "rx_retries": 0, "signal": -57, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 770834, "tx_packets": 8443, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5258, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 210, "dhcpstart_time": 210, "idletime": 49, "ip": "192.168.1.40", "is_11n": true, "mac": "0c:2a:69:02:3e:3b", "noise": -114, "rssi": 36, "rx_bytes": 427418, "rx_mcast": 0, "rx_packets": 2824, "rx_rate": 65000, "rx_retries": 0, "signal": -78, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 176039, "tx_packets": 2872, "tx_power": 48, "tx_rate": 65000, "tx_retries": 87, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 5030, "dhcpstart_time": 5030, "hostname": "HP2C27D78D9F3E", "idletime": 268, "ip": "192.168.1.44", "is_11n": true, "mac": "2c:27:d7:8d:9f:3e", "noise": -114, "rssi": 41, "rx_bytes": 172927, "rx_mcast": 0, "rx_packets": 781, "rx_rate": 72109, "rx_retries": 0, "signal": -73, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 41924, "tx_packets": 453, "tx_power": 48, "tx_rate": 66610, "tx_retries": 66, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 110, "dhcpstart_time": 110, "idletime": 4, "ip": "192.168.1.55", "is_11n": true, "mac": "0c:2a:69:04:e6:ac", "noise": -114, "rssi": 51, "rx_bytes": 300741, "rx_mcast": 0, "rx_packets": 2443, "rx_rate": 65000, "rx_retries": 0, "signal": -63, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 159980, "tx_packets": 2526, "tx_power": 48, "tx_rate": 65000, "tx_retries": 47, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 991, "dhcpend_time": 1570, "dhcpstart_time": 1560, "idletime": 1, "ip": "192.168.1.37", "is_11n": true, "mac": "0c:2a:69:03:df:37", "noise": -114, "rssi": 42, "rx_bytes": 304567, "rx_mcast": 0, "rx_packets": 2468, "rx_rate": 65000, "rx_retries": 0, "signal": -72, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 164382, "tx_packets": 2553, "tx_power": 48, "tx_rate": 65000, "tx_retries": 48, "uptime": 112373, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 1190129336, "tx_dropped": 7, "tx_errors": 0, "tx_packets": 1907093, "tx_power": 24, "tx_retries": 29927, "up": true, "usage": "user" }, { "bssid": "ff:ff:ff:ff:ff:ff", "ccq": 914, "channel": 157, "essid": "", "extchannel": 1, "id": "user", "name": "ath1", "num_sta": 0, "radio": "na", "rx_bytes": 0, "rx_crypts": 0, "rx_dropped": 0, "rx_errors": 0, "rx_frags": 0, "rx_nwids": 0, "rx_packets": 0, "sta_table": [], "state": "INIT", "tx_bytes": 0, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 0, "tx_power": 22, "tx_retries": 0, "up": false, "usage": "uplink" }, { "bssid": "82:2a:a8:98:36:3c", "ccq": 482, "channel": 157, "essid": "220 5ghz", "extchannel": 1, "id": "55b19c7e50e4e11e798e84c7", "name": "ath2", "num_sta": 3, "radio": "na", "rx_bytes": 250435644, "rx_crypts": 4071, "rx_dropped": 4071, "rx_errors": 4071, "rx_frags": 0, "rx_nwids": 6660, "rx_packets": 1123263, "sta_table": [ { "auth_time": 4294967246, "authorized": true, "ccq": 631, "dhcpend_time": 190, "dhcpstart_time": 190, "hostname": "android-f4aaefc31d5d2f78", "idletime": 26, "ip": "192.168.1.15", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "c0:ee:fb:24:ef:a0", "noise": -105, "rssi": 16, "rx_bytes": 3188995, "rx_mcast": 0, "rx_packets": 37243, "rx_rate": 81000, "rx_retries": 0, "signal": -89, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 89051905, "tx_packets": 64756, "tx_power": 44, "tx_rate": 108000, "tx_retries": 0, "uptime": 5494, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 333, "dhcpend_time": 10, "dhcpstart_time": 10, "hostname": "mac_book_air", "idletime": 1, "ip": "192.168.1.12", "is_11a": true, "is_11n": true, "mac": "00:88:65:56:34:12", "noise": -105, "rssi": 52, "rx_bytes": 106902966, "rx_mcast": 0, "rx_packets": 270845, "rx_rate": 300000, "rx_retries": 0, "signal": -53, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 289588466, "tx_packets": 339466, "tx_power": 44, "tx_rate": 300000, "tx_retries": 0, "uptime": 15312, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 333, "dhcpend_time": 160, "dhcpstart_time": 160, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.29", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "f4:f5:d8:11:57:6a", "noise": -105, "rssi": 40, "rx_bytes": 50958412, "rx_mcast": 0, "rx_packets": 339563, "rx_rate": 200000, "rx_retries": 0, "signal": -65, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 1186178689, "tx_packets": 890384, "tx_power": 44, "tx_rate": 150000, "tx_retries": 0, "uptime": 56493, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 2766849222, "tx_dropped": 119, "tx_errors": 23508, "tx_packets": 2247859, "tx_power": 22, "tx_retries": 0, "up": true, "usage": "user" } ], "version": "3.7.58.6385", "wifi_caps": 1909}' \ No newline at end of file diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py deleted file mode 100644 index cfb1c7e92bc..00000000000 --- a/tests/components/unifi_direct/test_device_tracker.py +++ /dev/null @@ -1,178 +0,0 @@ -"""The tests for the Unifi direct device tracker platform.""" -from datetime import timedelta -import os -from unittest.mock import MagicMock, call, patch - -import pytest -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, - CONF_NEW_DEVICE_DEFAULTS, - CONF_TRACK_NEW, -) -from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.unifi_direct.device_tracker import ( - CONF_PORT, - DOMAIN, - PLATFORM_SCHEMA, - UnifiDeviceScanner, - _response_to_json, - get_scanner, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture, mock_component - -scanner_path = "homeassistant.components.unifi_direct.device_tracker.UnifiDeviceScanner" - - -@pytest.fixture(autouse=True) -def setup_comp(hass): - """Initialize components.""" - mock_component(hass, "zone") - yaml_devices = hass.config.path(YAML_DEVICES) - yield - if os.path.isfile(yaml_devices): - os.remove(yaml_devices) - - -@patch(scanner_path, return_value=MagicMock(spec=UnifiDeviceScanner)) -async def test_get_scanner(unifi_mock, hass: HomeAssistant) -> None: - """Test creating an Unifi direct scanner with a password.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True}, - } - } - - with assert_setup_component(1, DOMAIN): - assert await async_setup_component(hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_PORT] = 22 - assert unifi_mock.call_args == call(conf_dict[DOMAIN]) - - -@patch("pexpect.pxssh.pxssh") -async def test_get_device_name(mock_ssh, hass: HomeAssistant) -> None: - """Testing MAC matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - mock_ssh.return_value.before = load_fixture("data.txt", "unifi_direct") - scanner = get_scanner(hass, conf_dict) - devices = scanner.scan_devices() - assert len(devices) == 23 - assert scanner.get_device_name("98:00:c6:56:34:12") == "iPhone" - assert scanner.get_device_name("98:00:C6:56:34:12") == "iPhone" - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login") -async def test_failed_to_log_in(mock_login, mock_logout, hass: HomeAssistant) -> None: - """Testing exception at login results in False.""" - from pexpect import exceptions - - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - mock_login.side_effect = exceptions.EOF("Test") - scanner = get_scanner(hass, conf_dict) - assert not scanner - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login", autospec=True) -@patch("pexpect.pxssh.pxssh.prompt") -@patch("pexpect.pxssh.pxssh.sendline") -async def test_to_get_update( - mock_sendline, mock_prompt, mock_login, mock_logout, hass: HomeAssistant -) -> None: - """Testing exception in get_update matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - scanner = get_scanner(hass, conf_dict) - # mock_sendline.side_effect = AssertionError("Test") - mock_prompt.side_effect = AssertionError("Test") - devices = scanner._get_update() - assert devices is None - - -def test_good_response_parses(hass: HomeAssistant) -> None: - """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture("data.txt", "unifi_direct")) - assert response != {} - - -def test_bad_response_returns_none(hass: HomeAssistant) -> None: - """Test that a bad response form the AP parses to JSON correctly.""" - assert _response_to_json("{(}") == {} - - -def test_config_error() -> None: - """Test for configuration errors.""" - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no username - CONF_PASSWORD: "password", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no password - CONF_USERNAME: "foo", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - CONF_PLATFORM: DOMAIN, - CONF_USERNAME: "foo", - CONF_PASSWORD: "password", - CONF_HOST: "myhost", - "port": "foo", # bad port! - } - ) From e764372d1e3fb2debca5e8565d8edbe811e812a4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 26 Dec 2023 15:12:37 +0100 Subject: [PATCH 728/927] Move cloud binary sensor to config entry (#106409) * Move cloud binary sensor to config entry * Fix docstring --- homeassistant/components/cloud/__init__.py | 3 +- .../components/cloud/binary_sensor.py | 28 ++++++------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index bf60ab9cc94..d7d57835e3a 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -63,7 +63,7 @@ from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD -PLATFORMS = [Platform.STT] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" @@ -284,7 +284,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: tts_info = {"platform_loaded": tts_platform_loaded} - await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) await tts_platform_loaded.wait() diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index e09122ac7bf..d56896dd7b1 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from typing import Any from hass_nabucasa import Cloud @@ -11,11 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN @@ -23,17 +22,13 @@ from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN WAIT_UNTIL_CHANGE = 3 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the cloud binary sensors.""" - if discovery_info is None: - return - cloud = hass.data[DOMAIN] - + """Set up the Home Assistant Cloud binary sensors.""" + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async_add_entities([CloudRemoteBinary(cloud)]) @@ -49,7 +44,6 @@ class CloudRemoteBinary(BinarySensorEntity): def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -69,12 +63,8 @@ class CloudRemoteBinary(BinarySensorEntity): await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() - self._unsub_dispatcher = async_dispatcher_connect( - self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) ) - - async def async_will_remove_from_hass(self) -> None: - """Register update dispatcher.""" - if self._unsub_dispatcher is not None: - self._unsub_dispatcher() - self._unsub_dispatcher = None From e9f28c206034d4456b2076776e7584ba4ba71a3b Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 26 Dec 2023 10:16:54 -0500 Subject: [PATCH 729/927] Redact unique id from diagnostics in blink (#106413) redact unique id --- homeassistant/components/blink/diagnostics.py | 2 +- tests/components/blink/conftest.py | 1 + tests/components/blink/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blink/diagnostics.py b/homeassistant/components/blink/diagnostics.py index f69c1721bf1..664d1421ac2 100644 --- a/homeassistant/components/blink/diagnostics.py +++ b/homeassistant/components/blink/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -TO_REDACT = {"serial", "macaddress", "username", "password", "token"} +TO_REDACT = {"serial", "macaddress", "username", "password", "token", "unique_id"} async def async_get_config_entry_diagnostics( diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index 946840c23b9..d7deaf39bd9 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -87,6 +87,7 @@ def mock_config_fixture(): "device_id": "Home Assistant", "uid": "BlinkCamera_e1233333e2-0909-09cd-777a-123456789012", "token": "A_token", + "unique_id": "an_email@email.com", "host": "u034.immedia-semi.com", "region_id": "u034", "client_id": 123456, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index a1c18223c11..44554dad1e3 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -34,6 +34,7 @@ 'region_id': 'u034', 'token': '**REDACTED**', 'uid': 'BlinkCamera_e1233333e2-0909-09cd-777a-123456789012', + 'unique_id': '**REDACTED**', 'username': '**REDACTED**', }), 'disabled_by': None, From c6d1f1ccc8b40c435d18ab359b73b42d44a6a415 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 26 Dec 2023 16:23:19 +0100 Subject: [PATCH 730/927] Fix pytest test collection warning (#106405) --- tests/helpers/test_deprecation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 8e776e98096..bd3546afb12 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -261,6 +261,8 @@ def test_deprecated_function_called_from_custom_integration( class TestDeprecatedConstantEnum(StrEnum): """Test deprecated constant enum.""" + __test__ = False # prevent test collection of class by pytest + TEST = "value" From 2cd6c2b6bfb31a16fd56cc7fc420c80017a17bff Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 Dec 2023 18:27:33 +0100 Subject: [PATCH 731/927] Add alarm platform to Comelit (#104718) * initial work on alarm * final work on alarm * coveragerc * add tests * add code validation * remove sensor changes for a dedicated PR * code optimization and cleanup * tweaks * tweak #2 * apply suggestion * code quality * code quality #2 * fix cover.py * api typing * use base classes where possibile * apply const as per review comment * cleanup unload entry * apply review comments --- .coveragerc | 1 + homeassistant/components/comelit/__init__.py | 52 ++++-- .../components/comelit/alarm_control_panel.py | 155 ++++++++++++++++++ .../components/comelit/config_flow.py | 19 ++- homeassistant/components/comelit/const.py | 3 + .../components/comelit/coordinator.py | 102 ++++++++---- homeassistant/components/comelit/cover.py | 2 +- homeassistant/components/comelit/light.py | 2 +- .../components/comelit/manifest.json | 2 +- homeassistant/components/comelit/sensor.py | 2 +- homeassistant/components/comelit/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/const.py | 15 +- tests/components/comelit/test_config_flow.py | 40 +++-- 15 files changed, 328 insertions(+), 73 deletions(-) create mode 100644 homeassistant/components/comelit/alarm_control_panel.py diff --git a/.coveragerc b/.coveragerc index cdc148e5f5c..a67ee911681 100644 --- a/.coveragerc +++ b/.coveragerc @@ -173,6 +173,7 @@ omit = homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py + homeassistant/components/comelit/alarm_control_panel.py homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index b271644234d..924d3bee4bb 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -1,38 +1,66 @@ """Comelit integration.""" +from aiocomelit.const import BRIDGE + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT, DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +BRIDGE_PLATFORMS = [ + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] +VEDO_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Comelit platform.""" - coordinator = ComelitSerialBridge( - hass, - entry.data[CONF_HOST], - entry.data.get(CONF_PORT, DEFAULT_PORT), - entry.data[CONF_PIN], - ) + + coordinator: ComelitBaseCoordinator + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + coordinator = ComelitSerialBridge( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = BRIDGE_PLATFORMS + else: + coordinator = ComelitVedoSystem( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = VEDO_PLATFORMS 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) + 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): - coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id] + + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + platforms = BRIDGE_PLATFORMS + else: + platforms = VEDO_PLATFORMS + + coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() await coordinator.api.close() hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py new file mode 100644 index 00000000000..7954fc05dd1 --- /dev/null +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -0,0 +1,155 @@ +"""Support for Comelit VEDO system.""" +from __future__ import annotations + +import logging + +from aiocomelit.api import ComelitVedoAreaObject +from aiocomelit.const import ALARM_AREAS, AlarmAreaState + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitVedoSystem + +_LOGGER = logging.getLogger(__name__) + +AWAY = "away" +DISABLE = "disable" +HOME = "home" +HOME_P1 = "home_p1" +HOME_P2 = "home_p2" +NIGHT = "night" + +ALARM_ACTIONS: dict[str, str] = { + DISABLE: "dis", # Disarm + HOME: "p1", # Arm P1 + NIGHT: "p12", # Arm P1+P2 + AWAY: "tot", # Arm P1+P2 + IR / volumetric +} + + +ALARM_AREA_ARMED_STATUS: dict[str, int] = { + HOME_P1: 1, + HOME_P2: 2, + NIGHT: 3, + AWAY: 4, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Comelit VEDO system alarm control panel devices.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitAlarmEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[ALARM_AREAS].values() + ) + + +class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity): + """Representation of a Ness alarm panel.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_code_format = CodeFormat.NUMBER + _attr_code_arm_required = False + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + def __init__( + self, + coordinator: ComelitVedoSystem, + area: ComelitVedoAreaObject, + config_entry_entry_id: str, + ) -> None: + """Initialize the alarm panel.""" + self._api = coordinator.api + self._area_index = area.index + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{area.index}" + self._attr_device_info = coordinator.platform_device_info(area, "area") + if area.p2: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + + @property + def _area(self) -> ComelitVedoAreaObject: + """Return area object.""" + return self.coordinator.data[ALARM_AREAS][self._area_index] + + @property + def available(self) -> bool: + """Return True if alarm is available.""" + if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + return False + return super().available + + @property + def state(self) -> StateType: + """Return the state of the alarm.""" + + _LOGGER.debug( + "Area %s status is: %s. Armed is %s", + self._area.name, + self._area.human_status, + self._area.armed, + ) + if self._area.human_status == AlarmAreaState.ARMED: + if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]: + return STATE_ALARM_ARMED_AWAY + if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]: + return STATE_ALARM_ARMED_NIGHT + return STATE_ALARM_ARMED_HOME + + { + AlarmAreaState.DISARMED: STATE_ALARM_DISARMED, + AlarmAreaState.ENTRY_DELAY: STATE_ALARM_DISARMING, + AlarmAreaState.EXIT_DELAY: STATE_ALARM_ARMING, + AlarmAreaState.TRIGGERED: STATE_ALARM_TRIGGERED, + }.get(self._area.human_status) + + return None + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if code != str(self._api.device_pin): + return + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index b95853edf9d..cbd79ac1e1a 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,16 +4,22 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, exceptions as aiocomelit_exceptions +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitVedoApi, + exceptions as aiocomelit_exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE import voluptuous as vol from homeassistant import core, exceptions from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import _LOGGER, DEFAULT_PORT, DOMAIN +from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 @@ -27,6 +33,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) @@ -39,7 +46,11 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api: ComelitCommonApi + if data.get(CONF_TYPE, BRIDGE) == BRIDGE: + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + else: + api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) try: await api.login() diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 57b7f35bc17..ca10e0b0a74 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -1,7 +1,10 @@ """Comelit constants.""" import logging +from aiocomelit.const import BRIDGE, VEDO + _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 +DEVICE_TYPE_LIST = [BRIDGE, VEDO] diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1573d5cb627..377ffec4ba4 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,9 +1,17 @@ """Support for Comelit.""" +from abc import abstractmethod from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions -from aiocomelit.const import BRIDGE +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitSerialBridgeObject, + ComelitVedoApi, + ComelitVedoAreaObject, + exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE, VEDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,19 +22,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import _LOGGER, DOMAIN -class ComelitSerialBridge(DataUpdateCoordinator): - """Queries Comelit Serial Bridge.""" +class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Base coordinator for Comelit Devices.""" + _hw_version: str config_entry: ConfigEntry + api: ComelitCommonApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__(self, hass: HomeAssistant, device: str, host: str) -> None: """Initialize the scanner.""" + self._device = device self._host = host - self._port = port - self._pin = pin - - self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__( hass=hass, @@ -38,43 +45,80 @@ class ComelitSerialBridge(DataUpdateCoordinator): device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, self.config_entry.entry_id)}, - model=BRIDGE, - name=f"{BRIDGE} ({self.api.host})", - **self.basic_device_info, + model=device, + name=f"{device} ({self._host})", + manufacturer="Comelit", + hw_version=self._hw_version, ) - @property - def basic_device_info(self) -> dict: - """Set basic device info.""" - - return { - "manufacturer": "Comelit", - "hw_version": "20003101", - } - - def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceInfo: + def platform_device_info( + self, + object_class: ComelitVedoAreaObject | ComelitSerialBridgeObject, + object_type: str, + ) -> dr.DeviceInfo: """Set platform device info.""" return dr.DeviceInfo( identifiers={ - (DOMAIN, f"{self.config_entry.entry_id}-{device.type}-{device.index}") + ( + DOMAIN, + f"{self.config_entry.entry_id}-{object_type}-{object_class.index}", + ) }, via_device=(DOMAIN, self.config_entry.entry_id), - name=device.name, - model=f"{BRIDGE} {device.type}", - **self.basic_device_info, + name=object_class.name, + model=f"{self._device} {object_type}", + manufacturer="Comelit", + hw_version=self._hw_version, ) async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" - _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) - + _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: await self.api.login() - return await self.api.get_all_devices() + return await self._async_update_system_data() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed + + return {} + + @abstractmethod + async def _async_update_system_data(self) -> dict[str, Any]: + """Class method for updating data.""" + + +class ComelitSerialBridge(ComelitBaseCoordinator): + """Queries Comelit Serial Bridge.""" + + _hw_version = "20003101" + api: ComeliteSerialBridgeApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComeliteSerialBridgeApi(host, port, pin) + super().__init__(hass, BRIDGE, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" + return await self.api.get_all_devices() + + +class ComelitVedoSystem(ComelitBaseCoordinator): + """Queries Comelit VEDO system.""" + + _hw_version = "VEDO IP" + api: ComelitVedoApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComelitVedoApi(host, port, pin) + super().__init__(hass, VEDO, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" + return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 72bbf56e08a..d35180c761b 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -54,7 +54,7 @@ class ComelitCoverEntity( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 95906f7ec6e..7deb3d49624 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -50,7 +50,7 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 89157b54255..8b50ccdf767 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.6.2"] + "requirements": ["aiocomelit==0.7.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 554433fa6ad..79b1db98356 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -69,7 +69,7 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) self.entity_description = description diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 379b936c3bb..ce08c64fa78 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -56,7 +56,7 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET diff --git a/requirements_all.txt b/requirements_all.txt index 851e9c2f6e1..47a37387974 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.6.2 +aiocomelit==0.7.0 # homeassistant.components.dhcp aiodiscover==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a76da0a37d1..51c872436aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.6.2 +aiocomelit==0.7.0 # homeassistant.components.dhcp aiodiscover==1.6.0 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 10999b04bea..998c12c09b7 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,9 @@ """Common stuff for Comelit SimpleHome tests.""" +from aiocomelit.const import VEDO + from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE MOCK_CONFIG = { DOMAIN: { @@ -10,11 +12,18 @@ MOCK_CONFIG = { CONF_HOST: "fake_host", CONF_PORT: 80, CONF_PIN: 1234, - } + }, + { + CONF_HOST: "fake_vedo_host", + CONF_PORT: 8080, + CONF_PIN: 1234, + CONF_TYPE: VEDO, + }, ] } } -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] FAKE_PIN = 5678 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index dd15eca05cd..f17c46c6f5b 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Comelit SimpleHome config flow.""" +from typing import Any from unittest.mock import patch from aiocomelit import CannotAuthenticate, CannotConnect @@ -10,24 +11,27 @@ from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_DATA +from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("class_api", "user_input"), + [ + ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), + ("ComelitVedoApi", MOCK_USER_VEDO_DATA), + ], +) +async def test_full_flow( + hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +) -> None: """Test starting a flow by user.""" with patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", + f"aiocomelit.api.{class_api}.login", ), patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), patch( - "homeassistant.components.comelit.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + f"aiocomelit.api.{class_api}.logout", + ), patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,12 +39,12 @@ async def test_user(hass: HomeAssistant) -> None: assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA + result["flow_id"], user_input=user_input ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_PIN] == 1234 + assert result["data"][CONF_HOST] == user_input[CONF_HOST] + assert result["data"][CONF_PORT] == user_input[CONF_PORT] + assert result["data"][CONF_PIN] == user_input[CONF_PIN] assert not result["result"].unique_id await hass.async_block_till_done() @@ -73,7 +77,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> "homeassistant.components.comelit.async_setup_entry", ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA + result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA ) assert result["type"] == FlowResultType.FORM @@ -84,7 +88,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> async def test_reauth_successful(hass: HomeAssistant) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( @@ -128,7 +132,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: """Test starting a reauthentication flow but no connection found.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( From 4f0ee20ec5dbad7bfcbd8a4940f83edbc629d183 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 26 Dec 2023 18:29:32 +0100 Subject: [PATCH 732/927] Add config flow to System Monitor (#104906) * Initial commit for config flow to System Monitor * sensors * Fixes * Works * Add import * entity_registry_enabled_default = False * entity_category = diagnostic * Create issue * issue in config flow * Tests * test requirement * codeowner * Fix names * processes * Fix type * reviews * get info during startup once * Select process * Legacy import of resources * requirements * Allow custom * Fix tests * strings * strings * Always enable process sensors * Fix docstrings * skip remove sensors if no sensors * Modify sensors * Fix tests --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/systemmonitor/__init__.py | 26 +- .../components/systemmonitor/config_flow.py | 143 ++++++++++ .../components/systemmonitor/const.py | 17 ++ .../components/systemmonitor/manifest.json | 3 +- .../components/systemmonitor/sensor.py | 157 ++++++++-- .../components/systemmonitor/strings.json | 25 ++ .../components/systemmonitor/util.py | 42 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/systemmonitor/__init__.py | 1 + tests/components/systemmonitor/conftest.py | 17 ++ .../systemmonitor/test_config_flow.py | 270 ++++++++++++++++++ 15 files changed, 687 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/systemmonitor/config_flow.py create mode 100644 homeassistant/components/systemmonitor/const.py create mode 100644 homeassistant/components/systemmonitor/strings.json create mode 100644 homeassistant/components/systemmonitor/util.py create mode 100644 tests/components/systemmonitor/__init__.py create mode 100644 tests/components/systemmonitor/conftest.py create mode 100644 tests/components/systemmonitor/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a67ee911681..590f69961e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1305,7 +1305,9 @@ omit = homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/system_bridge/update.py + homeassistant/components/systemmonitor/__init__.py homeassistant/components/systemmonitor/sensor.py + homeassistant/components/systemmonitor/util.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py homeassistant/components/tado/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index b3d5475379e..b50af486033 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1297,6 +1297,8 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 +/homeassistant/components/systemmonitor/ @gjohansson-ST +/tests/components/systemmonitor/ @gjohansson-ST /homeassistant/components/tado/ @michaelarnauts @chiefdragon /tests/components/tado/ @michaelarnauts @chiefdragon /homeassistant/components/tag/ @balloob @dmulcahey diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 5ab8ac9f930..69dbb1f7952 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -1 +1,25 @@ -"""The systemmonitor integration.""" +"""The System Monitor integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up System Monitor from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload System Monitor config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py new file mode 100644 index 00000000000..3dc45480aee --- /dev/null +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -0,0 +1,143 @@ +"""Adds config flow for System Monitor.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util import slugify + +from .const import CONF_PROCESS, DOMAIN +from .util import get_all_running_processes + + +async def validate_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + processes = sensors.setdefault(CONF_PROCESS, []) + previous_processes = processes.copy() + processes.clear() + processes.extend(user_input[CONF_PROCESS]) + + entity_registry = er.async_get(handler.parent_handler.hass) + for process in previous_processes: + if process not in processes and ( + entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, slugify(f"process_{process}") + ) + ): + entity_registry.async_remove(entity_id) + + return {} + + +async def validate_import_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + import_processes: list[str] = user_input["processes"] + processes = sensors.setdefault(CONF_PROCESS, []) + processes.extend(import_processes) + legacy_resources: list[str] = handler.options.setdefault("resources", []) + legacy_resources.extend(user_input["legacy_resources"]) + + async_create_issue( + handler.parent_handler.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "System Monitor", + }, + ) + return {} + + +async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return process sensor setup schema.""" + hass = handler.parent_handler.hass + processes = await hass.async_add_executor_job(get_all_running_processes) + return vol.Schema( + { + vol.Required(CONF_PROCESS): SelectSelector( + SelectSelectorConfig( + options=processes, + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ) + } + ) + + +async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any]: + """Return suggested values for sensor setup.""" + sensors: dict[str, list] = handler.options.get(SENSOR_DOMAIN, {}) + processes: list[str] = sensors.get(CONF_PROCESS, []) + return {CONF_PROCESS: processes} + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep(schema=vol.Schema({})), + "import": SchemaFlowFormStep( + schema=vol.Schema({}), + validate_user_input=validate_import_sensor_setup, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + get_sensor_setup_schema, + suggested_values=get_suggested_value, + validate_user_input=validate_sensor_setup, + ) +} + + +class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for System Monitor.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return "System Monitor" + + @callback + def async_create_entry(self, data: Mapping[str, Any], **kwargs: Any) -> FlowResult: + """Finish config flow and create a config entry.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return super().async_create_entry(data, **kwargs) diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py new file mode 100644 index 00000000000..c92647f9c8e --- /dev/null +++ b/homeassistant/components/systemmonitor/const.py @@ -0,0 +1,17 @@ +"""Constants for System Monitor.""" + +DOMAIN = "systemmonitor" + +CONF_INDEX = "index" +CONF_PROCESS = "process" + +NETWORK_TYPES = [ + "network_in", + "network_out", + "throughput_network_in", + "throughput_network_out", + "packets_in", + "packets_out", + "ipv4_address", + "ipv6_address", +] diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 3288f4299dc..213fa9cf6be 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -1,7 +1,8 @@ { "domain": "systemmonitor", "name": "System Monitor", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index f34686ca3da..57838c45dc7 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -15,26 +15,29 @@ import psutil import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_OFF, STATE_ON, + EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -46,6 +49,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from .const import CONF_PROCESS, DOMAIN, NETWORK_TYPES +from .util import get_all_disk_mounts, get_all_network_interfaces + _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" @@ -261,6 +267,17 @@ def check_required_arg(value: Any) -> Any: return value +def check_legacy_resource(resource: str, resources: list[str]) -> bool: + """Return True if legacy resource was configured.""" + # This function to check legacy resources can be removed + # once we are removing the import from YAML + if resource in resources: + _LOGGER.debug("Checking %s in %s returns True", resource, ", ".join(resources)) + return True + _LOGGER.debug("Checking %s in %s returns False", resource, ", ".join(resources)) + return False + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default={CONF_TYPE: "disk_use"}): vol.All( @@ -334,39 +351,126 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the system monitor sensors.""" + processes = [ + resource[CONF_ARG] + for resource in config[CONF_RESOURCES] + if resource[CONF_TYPE] == "process" + ] + legacy_config: list[dict[str, str]] = config[CONF_RESOURCES] + resources = [] + for resource_conf in legacy_config: + if (_type := resource_conf[CONF_TYPE]).startswith("disk_"): + if (arg := resource_conf.get(CONF_ARG)) is None: + resources.append(f"{_type}_/") + continue + resources.append(f"{_type}_{arg}") + continue + resources.append(f"{_type}_{resource_conf.get(CONF_ARG, '')}") + _LOGGER.debug( + "Importing config with processes: %s, resources: %s", processes, resources + ) + + # With removal of the import also cleanup legacy_resources logic in setup_entry + # Also cleanup entry.options["resources"] which is only imported for legacy reasons + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"processes": processes, "legacy_resources": resources}, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up System Montor sensors based on a config entry.""" entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} + legacy_resources: list[str] = entry.options.get("resources", []) + disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) + network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) + cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) - for resource in config[CONF_RESOURCES]: - type_ = resource[CONF_TYPE] - # Initialize the sensor argument if none was provided. - # For disk monitoring default to "/" (root) to prevent runtime errors, if argument was not specified. - if CONF_ARG not in resource: - argument = "" - if resource[CONF_TYPE].startswith("disk_"): - argument = "/" - else: - argument = resource[CONF_ARG] + _LOGGER.debug("Setup from options %s", entry.options) + + for _type, sensor_description in SENSOR_TYPES.items(): + if _type.startswith("disk_"): + for argument in disk_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type in NETWORK_TYPES: + for argument in network_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue # Verify if we can retrieve CPU / processor temperatures. # If not, do not create the entity and add a warning to the log - if ( - type_ == "processor_temperature" - and await hass.async_add_executor_job(_read_cpu_temperature) is None - ): + if _type == "processor_temperature" and cpu_temperature is None: _LOGGER.warning("Cannot read CPU / processor temperature information") continue - sensor_registry[(type_, argument)] = SensorData( - argument, None, None, None, None - ) + if _type == "process": + _entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {}) + for argument in _entry.get(CONF_PROCESS, []): + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + True, + ) + ) + continue + + sensor_registry[(_type, "")] = SensorData("", None, None, None, None) + is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) entities.append( - SystemMonitorSensor(sensor_registry, SENSOR_TYPES[type_], argument) + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + "", + is_enabled, + ) ) - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + scan_interval = DEFAULT_SCAN_INTERVAL await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) - async_add_entities(entities) @@ -433,12 +537,16 @@ class SystemMonitorSensor(SensorEntity): """Implementation of a system monitor sensor.""" should_poll = False + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, sensor_registry: dict[tuple[str, str], SensorData], sensor_description: SysMonitorSensorEntityDescription, + entry_id: str, argument: str = "", + legacy_enabled: bool = False, ) -> None: """Initialize the sensor.""" self.entity_description = sensor_description @@ -446,6 +554,13 @@ class SystemMonitorSensor(SensorEntity): self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument + self._attr_entity_registry_enabled_default = legacy_enabled + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="System Monitor", + name="System Monitor", + ) @property def native_value(self) -> str | datetime | None: diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json new file mode 100644 index 00000000000..88ecad4b107 --- /dev/null +++ b/homeassistant/components/systemmonitor/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "step": { + "user": { + "description": "Press submit for initial setup. On the created config entry, press configure to add sensors for selected processes" + } + } + }, + "options": { + "step": { + "init": { + "description": "Configure a monitoring sensor for a running process", + "data": { + "process": "Processes to add as sensor(s)" + }, + "data_description": { + "process": "Select a running process from the list or add a custom value. Multiple selections/custom values are supported" + } + } + } + } +} diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py new file mode 100644 index 00000000000..bb81d0c9715 --- /dev/null +++ b/homeassistant/components/systemmonitor/util.py @@ -0,0 +1,42 @@ +"""Utils for System Monitor.""" + +import logging +import os + +import psutil + +_LOGGER = logging.getLogger(__name__) + + +def get_all_disk_mounts() -> list[str]: + """Return all disk mount points on system.""" + disks: list[str] = [] + for part in psutil.disk_partitions(all=False): + if os.name == "nt": + if "cdrom" in part.opts or part.fstype == "": + # skip cd-rom drives with no disk in it; they may raise + # ENOENT, pop-up a Windows GUI error for a non-ready + # partition or just hang. + continue + disks.append(part.mountpoint) + _LOGGER.debug("Adding disks: %s", ", ".join(disks)) + return disks + + +def get_all_network_interfaces() -> list[str]: + """Return all network interfaces on system.""" + interfaces: list[str] = [] + for interface, _ in psutil.net_if_addrs().items(): + interfaces.append(interface) + _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) + return interfaces + + +def get_all_running_processes() -> list[str]: + """Return all running processes on system.""" + processes: list[str] = [] + for proc in psutil.process_iter(["name"]): + if proc.name() not in processes: + processes.append(proc.name()) + _LOGGER.debug("Running processes: %s", ", ".join(processes)) + return processes diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dded0147422..d2674e128ce 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -490,6 +490,7 @@ FLOWS = { "syncthru", "synology_dsm", "system_bridge", + "systemmonitor", "tado", "tailscale", "tailwind", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d80f4f18925..d8ba63322ca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5731,7 +5731,7 @@ "systemmonitor": { "name": "System Monitor", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "tado": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51c872436aa..5007ed4262e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1176,6 +1176,9 @@ prometheus-client==0.17.1 # homeassistant.components.recorder psutil-home-assistant==0.0.1 +# homeassistant.components.systemmonitor +psutil==5.9.7 + # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 diff --git a/tests/components/systemmonitor/__init__.py b/tests/components/systemmonitor/__init__.py new file mode 100644 index 00000000000..92e60c1dbb2 --- /dev/null +++ b/tests/components/systemmonitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the System Monitor component.""" diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py new file mode 100644 index 00000000000..ca21c971cf1 --- /dev/null +++ b/tests/components/systemmonitor/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the System Monitor integration.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setup entry.""" + with patch( + "homeassistant.components.systemmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py new file mode 100644 index 00000000000..367d38b91aa --- /dev/null +++ b/tests/components/systemmonitor/test_config_flow.py @@ -0,0 +1,270 @@ +"""Test the System Monitor config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.systemmonitor.const import CONF_PROCESS, DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.util import slugify + +from tests.common import MockConfigEntry + + +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["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == {} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == { + "sensor": {"process": ["systemd", "octave-cli"]}, + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + } + + assert len(mock_setup_entry.mock_calls) == 1 + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test abort when already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test abort when already configured for import.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={ + "sensor": [{CONF_PROCESS: "systemd"}], + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_add_and_remove_processes( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test adding and removing process sensors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + } + } + + # Add another + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd", "octave-cli"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd", "octave-cli"], + }, + } + + entity_reg = er.async_get(hass) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_systemd"), + config_entry=config_entry, + ) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_octave-cli"), + config_entry=config_entry, + ) + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is not None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is not None + + # Remove one + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + }, + } + + # Remove last + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": {CONF_PROCESS: []}, + } + + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is None From 9b740c1111174e0de8549dcd47922f46ab2e0da1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 03:57:30 +1000 Subject: [PATCH 733/927] Add missing translations to Tessie Button platform (#106232) Add missing translations --- homeassistant/components/tessie/strings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index cb4b09ad3a4..4d95681046e 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -255,7 +255,9 @@ "honk": { "name": "Honk horn" }, "trigger_homelink": { "name": "Homelink" }, "enable_keyless_driving": { "name": "Keyless driving" }, - "boombox": { "name": "Play fart" } + "boombox": { "name": "Play fart" }, + "frunk": { "name": "Open frunk" }, + "trunk": { "name": "Open/Close trunk" } }, "switch": { "charge_state_charge_enable_request": { From 8be8524955727385cd963536de259f1fcc84cb85 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 Dec 2023 18:59:40 +0100 Subject: [PATCH 734/927] Fix freeze entity description (#106418) --- homeassistant/components/vodafone_station/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index fcf26c6eb55..b383c2d193a 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -34,7 +34,7 @@ class VodafoneStationBaseEntityDescription: is_suitable: Callable[[dict], bool] = lambda val: True -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class VodafoneStationEntityDescription( VodafoneStationBaseEntityDescription, SensorEntityDescription ): From c7b4f8f78046824e12596923e668ba2b56d2f406 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 10:48:31 -1000 Subject: [PATCH 735/927] Use faster contains check in camera for stream feature (#106429) --- homeassistant/components/camera/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index f7552e79468..0414106a978 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -233,7 +233,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if not camera.stream and camera.supported_features & CameraEntityFeature.STREAM: + if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features: camera.stream = await camera.async_create_stream() if camera.stream: return await camera.stream.async_get_image( @@ -564,7 +564,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ if hasattr(self, "_attr_frontend_stream_type"): return self._attr_frontend_stream_type - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features: return None if self._rtsp_to_webrtc: return StreamType.WEB_RTC @@ -752,7 +752,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def _async_use_rtsp_to_webrtc(self) -> bool: """Determine if a WebRTC provider can be used for the camera.""" - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features: return False if DATA_RTSP_TO_WEB_RTC not in self.hass.data: return False From 01ded7daea399cc11ee2ff65247d07d4255fa3b8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Dec 2023 22:24:28 +0100 Subject: [PATCH 736/927] Add config flow to Streamlabs water (#104962) * Add config flow to Streamlabs water * Add config flow to Streamlabs water * Add config flow to Streamlabs water * Add issue when import is successful * Remove import issue when entry already exists * Remove import issue when entry already exists * Fix feedback * Fix feedback --------- Co-authored-by: G Johansson --- .coveragerc | 5 +- .../components/streamlabswater/__init__.py | 114 +++++++---- .../streamlabswater/binary_sensor.py | 84 +++----- .../components/streamlabswater/config_flow.py | 75 +++++++ .../components/streamlabswater/const.py | 6 + .../components/streamlabswater/coordinator.py | 57 ++++++ .../components/streamlabswater/manifest.json | 1 + .../components/streamlabswater/sensor.py | 120 ++++------- .../components/streamlabswater/services.yaml | 3 + .../components/streamlabswater/strings.json | 30 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/streamlabswater/__init__.py | 1 + tests/components/streamlabswater/conftest.py | 14 ++ .../streamlabswater/test_config_flow.py | 193 ++++++++++++++++++ 16 files changed, 530 insertions(+), 179 deletions(-) create mode 100644 homeassistant/components/streamlabswater/config_flow.py create mode 100644 homeassistant/components/streamlabswater/const.py create mode 100644 homeassistant/components/streamlabswater/coordinator.py create mode 100644 tests/components/streamlabswater/__init__.py create mode 100644 tests/components/streamlabswater/conftest.py create mode 100644 tests/components/streamlabswater/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 590f69961e2..ee8e165c9b6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1243,7 +1243,10 @@ omit = homeassistant/components/stream/fmp4utils.py homeassistant/components/stream/hls.py homeassistant/components/stream/worker.py - homeassistant/components/streamlabswater/* + homeassistant/components/streamlabswater/__init__.py + homeassistant/components/streamlabswater/binary_sensor.py + homeassistant/components/streamlabswater/coordinator.py + homeassistant/components/streamlabswater/sensor.py homeassistant/components/suez_water/__init__.py homeassistant/components/suez_water/sensor.py homeassistant/components/supervisord/sensor.py diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index c09f6040fed..986b5de8049 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,28 +1,31 @@ """Support for Streamlabs Water Monitor devices.""" -import logging -from streamlabswater import streamlabswater +from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -DOMAIN = "streamlabswater" - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import StreamlabsCoordinator ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" AWAY_MODE_AWAY = "away" AWAY_MODE_HOME = "home" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] - CONF_LOCATION_ID = "location_id" +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -36,52 +39,77 @@ CONFIG_SCHEMA = vol.Schema( ) SET_AWAY_MODE_SCHEMA = vol.Schema( - {vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME])} + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } ) +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the streamlabs water integration.""" - conf = config[DOMAIN] - api_key = conf.get(CONF_API_KEY) - location_id = conf.get(CONF_LOCATION_ID) + if DOMAIN not in config: + return True - client = streamlabswater.StreamlabsClient(api_key) - locations = client.get_locations().get("locations") - - if locations is None: - _LOGGER.error("Unable to retrieve locations. Verify API key") - return False - - if location_id is None: - location = locations[0] - location_id = location["locationId"] - _LOGGER.info( - "Streamlabs Water Monitor auto-detected location_id=%s", location_id + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config[DOMAIN][CONF_API_KEY]}, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "StreamLabs", + }, ) else: - location = next( - (loc for loc in locations if location_id == loc["locationId"]), None + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, ) - if location is None: - _LOGGER.error("Supplied location_id is invalid") - return False + return True - location_name = location["name"] - hass.data[DOMAIN] = { - "client": client, - "location_id": location_id, - "location_name": location_name, - } +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up StreamLabs from a config entry.""" - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + api_key = entry.data[CONF_API_KEY] + client = StreamlabsClient(api_key) + coordinator = StreamlabsCoordinator(hass, client) + + 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) def set_away_mode(service: ServiceCall) -> None: """Set the StreamLabsWater Away Mode.""" away_mode = service.data.get(ATTR_AWAY_MODE) + location_id = ( + service.data.get(CONF_LOCATION_ID) or list(coordinator.data.values())[0] + ) client.update_location(location_id, away_mode) hass.services.register( @@ -89,3 +117,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) 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[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 4a974077592..d0ca500ded4 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -1,84 +1,54 @@ """Support for Streamlabs Water Monitor Away Mode.""" from __future__ import annotations -from datetime import timedelta - -from streamlabswater.streamlabswater import StreamlabsClient - from homeassistant.components.binary_sensor import BinarySensorEntity +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 -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN as STREAMLABSWATER_DOMAIN +from . import StreamlabsCoordinator +from .const import DOMAIN +from .coordinator import StreamlabsData -DEPENDS = ["streamlabswater"] - -MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60) - -ATTR_LOCATION_ID = "location_id" NAME_AWAY_MODE = "Water Away Mode" -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the StreamLabsWater mode sensor.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] + """Set up Streamlabs water binary sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] - streamlabs_location_data = StreamlabsLocationData(location_id, client) - streamlabs_location_data.update() + entities = [] - add_devices([StreamlabsAwayMode(location_name, streamlabs_location_data)]) + for location_id in coordinator.data: + entities.append(StreamlabsAwayMode(coordinator, location_id)) + + async_add_entities(entities) -class StreamlabsLocationData: - """Track and query location data.""" - - def __init__(self, location_id: str, client: StreamlabsClient) -> None: - """Initialize the location data.""" - self._location_id = location_id - self._client = client - self._is_away = None - - @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self) -> None: - """Query and store location data.""" - location = self._client.get_location(self._location_id) - self._is_away = location["homeAway"] == "away" - - def is_away(self) -> bool | None: - """Return whether away more is enabled.""" - return self._is_away - - -class StreamlabsAwayMode(BinarySensorEntity): +class StreamlabsAwayMode(CoordinatorEntity[StreamlabsCoordinator], BinarySensorEntity): """Monitor the away mode state.""" - def __init__( - self, location_name: str, streamlabs_location_data: StreamlabsLocationData - ) -> None: + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the away mode device.""" - self._location_name = location_name - self._streamlabs_location_data = streamlabs_location_data - self._is_away = None + super().__init__(coordinator) + self._location_id = location_id + + @property + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] @property def name(self) -> str: """Return the name for away mode.""" - return f"{self._location_name} {NAME_AWAY_MODE}" + return f"{self.location_data.name} {NAME_AWAY_MODE}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return if away mode is on.""" - return self._streamlabs_location_data.is_away() - - def update(self) -> None: - """Retrieve the latest location data and away mode state.""" - self._streamlabs_location_data.update() + return self.location_data.is_away diff --git a/homeassistant/components/streamlabswater/config_flow.py b/homeassistant/components/streamlabswater/config_flow.py new file mode 100644 index 00000000000..5cede037d5a --- /dev/null +++ b/homeassistant/components/streamlabswater/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for StreamLabs integration.""" +from __future__ import annotations + +from typing import Any + +from streamlabswater.streamlabswater import StreamlabsClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, LOGGER + + +async def validate_input(hass: HomeAssistant, api_key: str) -> None: + """Validate the user input allows us to connect.""" + client = StreamlabsClient(api_key) + response = await hass.async_add_executor_job(client.get_locations) + locations = response.get("locations") + + if locations is None: + raise CannotConnect + + +class StreamlabsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for StreamLabs.""" + + VERSION = 1 + + 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: + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Streamlabs", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry(title="Streamlabs", data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/streamlabswater/const.py b/homeassistant/components/streamlabswater/const.py new file mode 100644 index 00000000000..ee407d376d4 --- /dev/null +++ b/homeassistant/components/streamlabswater/const.py @@ -0,0 +1,6 @@ +"""Constants for the StreamLabs integration.""" +import logging + +DOMAIN = "streamlabswater" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py new file mode 100644 index 00000000000..a11eced5a6e --- /dev/null +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator for Streamlabs water integration.""" +from dataclasses import dataclass +from datetime import timedelta + +from streamlabswater.streamlabswater import StreamlabsClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +@dataclass(slots=True) +class StreamlabsData: + """Class to hold Streamlabs data.""" + + is_away: bool + name: str + daily_usage: float + monthly_usage: float + yearly_usage: float + + +class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): + """Coordinator for Streamlabs.""" + + def __init__( + self, + hass: HomeAssistant, + client: StreamlabsClient, + ) -> None: + """Coordinator for Streamlabs.""" + super().__init__( + hass, + LOGGER, + name="Streamlabs", + update_interval=timedelta(seconds=60), + ) + self.client = client + + async def _async_update_data(self) -> dict[str, StreamlabsData]: + return await self.hass.async_add_executor_job(self._update_data) + + def _update_data(self) -> dict[str, StreamlabsData]: + locations = self.client.get_locations() + res = {} + for location in locations: + location_id = location["locationId"] + water_usage = self.client.get_water_usage_summary(location_id) + res[location_id] = StreamlabsData( + is_away=location["homeAway"] == "away", + name=location["name"], + daily_usage=round(water_usage["today"], 1), + monthly_usage=round(water_usage["thisMonth"], 1), + yearly_usage=round(water_usage["thisYear"], 1), + ) + return res diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json index fae19ca3e7a..ec076bd52ec 100644 --- a/homeassistant/components/streamlabswater/manifest.json +++ b/homeassistant/components/streamlabswater/manifest.json @@ -2,6 +2,7 @@ "domain": "streamlabswater", "name": "StreamLabs", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/streamlabswater", "iot_class": "cloud_polling", "loggers": ["streamlabswater"], diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42e551c5c11..0b249b7c4e5 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,111 +1,69 @@ """Support for Streamlabs Water Monitor Usage.""" from __future__ import annotations -from datetime import timedelta - -from streamlabswater.streamlabswater import StreamlabsClient - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN as STREAMLABSWATER_DOMAIN - -DEPENDENCIES = ["streamlabswater"] - -WATER_ICON = "mdi:water" -MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60) +from . import StreamlabsCoordinator +from .const import DOMAIN +from .coordinator import StreamlabsData NAME_DAILY_USAGE = "Daily Water" NAME_MONTHLY_USAGE = "Monthly Water" NAME_YEARLY_USAGE = "Yearly Water" -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up water usage sensors.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] + """Set up Streamlabs water sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] - streamlabs_usage_data = StreamlabsUsageData(location_id, client) - streamlabs_usage_data.update() + entities = [] - add_devices( - [ - StreamLabsDailyUsage(location_name, streamlabs_usage_data), - StreamLabsMonthlyUsage(location_name, streamlabs_usage_data), - StreamLabsYearlyUsage(location_name, streamlabs_usage_data), - ] - ) + for location_id in coordinator.data.values(): + entities.extend( + [ + StreamLabsDailyUsage(coordinator, location_id), + StreamLabsMonthlyUsage(coordinator, location_id), + StreamLabsYearlyUsage(coordinator, location_id), + ] + ) + + async_add_entities(entities) -class StreamlabsUsageData: - """Track and query usage data.""" - - def __init__(self, location_id: str, client: StreamlabsClient) -> None: - """Initialize the usage data.""" - self._location_id = location_id - self._client = client - self._today = None - self._this_month = None - self._this_year = None - - @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self) -> None: - """Query and store usage data.""" - water_usage = self._client.get_water_usage_summary(self._location_id) - self._today = round(water_usage["today"], 1) - self._this_month = round(water_usage["thisMonth"], 1) - self._this_year = round(water_usage["thisYear"], 1) - - def get_daily_usage(self) -> float | None: - """Return the day's usage.""" - return self._today - - def get_monthly_usage(self) -> float | None: - """Return the month's usage.""" - return self._this_month - - def get_yearly_usage(self) -> float | None: - """Return the year's usage.""" - return self._this_year - - -class StreamLabsDailyUsage(SensorEntity): +class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): """Monitors the daily water usage.""" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__( - self, location_name: str, streamlabs_usage_data: StreamlabsUsageData - ) -> None: + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the daily water usage device.""" - self._location_name = location_name - self._streamlabs_usage_data = streamlabs_usage_data - self._state = None + super().__init__(coordinator) + self._location_id = location_id + + @property + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] @property def name(self) -> str: """Return the name for daily usage.""" - return f"{self._location_name} {NAME_DAILY_USAGE}" + return f"{self.location_data.name} {NAME_DAILY_USAGE}" @property - def native_value(self) -> float | None: + def native_value(self) -> float: """Return the current daily usage.""" - return self._streamlabs_usage_data.get_daily_usage() - - def update(self) -> None: - """Retrieve the latest daily usage.""" - self._streamlabs_usage_data.update() + return self.location_data.daily_usage class StreamLabsMonthlyUsage(StreamLabsDailyUsage): @@ -114,12 +72,12 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): @property def name(self) -> str: """Return the name for monthly usage.""" - return f"{self._location_name} {NAME_MONTHLY_USAGE}" + return f"{self.location_data.name} {NAME_MONTHLY_USAGE}" @property - def native_value(self) -> float | None: + def native_value(self) -> float: """Return the current monthly usage.""" - return self._streamlabs_usage_data.get_monthly_usage() + return self.location_data.monthly_usage class StreamLabsYearlyUsage(StreamLabsDailyUsage): @@ -128,9 +86,9 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): @property def name(self) -> str: """Return the name for yearly usage.""" - return f"{self._location_name} {NAME_YEARLY_USAGE}" + return f"{self.location_data.name} {NAME_YEARLY_USAGE}" @property - def native_value(self) -> float | None: + def native_value(self) -> float: """Return the current yearly usage.""" - return self._streamlabs_usage_data.get_yearly_usage() + return self.location_data.yearly_usage diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml index 7504a911123..cd828fd3fed 100644 --- a/homeassistant/components/streamlabswater/services.yaml +++ b/homeassistant/components/streamlabswater/services.yaml @@ -7,3 +7,6 @@ set_away_mode: options: - "away" - "home" + location_id: + selector: + text: diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index 56b35ab1044..e6b5dd7465b 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "set_away_mode": { "name": "Set away mode", @@ -7,8 +23,22 @@ "away_mode": { "name": "Away mode", "description": "Home or away." + }, + "location_id": { + "name": "Location ID", + "description": "The location ID of the Streamlabs Water Monitor." } } } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Streamlabs water works and restart Home Assistant to try again or remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d2674e128ce..7da240ac266 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -476,6 +476,7 @@ FLOWS = { "steamist", "stookalert", "stookwijzer", + "streamlabswater", "subaru", "suez_water", "sun", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d8ba63322ca..3a1e154facb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5585,7 +5585,7 @@ "streamlabswater": { "name": "StreamLabs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "subaru": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5007ed4262e..83df72a45c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1930,6 +1930,9 @@ stookalert==0.1.4 # homeassistant.components.stookwijzer stookwijzer==1.3.0 +# homeassistant.components.streamlabswater +streamlabswater==1.0.1 + # homeassistant.components.huawei_lte # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py new file mode 100644 index 00000000000..16b2e5f0974 --- /dev/null +++ b/tests/components/streamlabswater/__init__.py @@ -0,0 +1 @@ +"""Tests for the StreamLabs integration.""" diff --git a/tests/components/streamlabswater/conftest.py b/tests/components/streamlabswater/conftest.py new file mode 100644 index 00000000000..f871332e5f6 --- /dev/null +++ b/tests/components/streamlabswater/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the StreamLabs 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.streamlabswater.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/streamlabswater/test_config_flow.py b/tests/components/streamlabswater/test_config_flow.py new file mode 100644 index 00000000000..68f671d3b8c --- /dev/null +++ b/tests/components/streamlabswater/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the StreamLabs config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.streamlabswater.const import 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, 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.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + 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.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 728bef20d6071a147b90307c5c734a9dfe44e959 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 07:54:28 +1000 Subject: [PATCH 737/927] Add more binary sensors to Tessie (#106212) --- .../components/tessie/binary_sensor.py | 20 +++++++++++++++++++ homeassistant/components/tessie/strings.json | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index aab20763609..5edbb108568 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -110,6 +110,26 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4d95681046e..473f67888cd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -247,6 +247,18 @@ }, "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" + }, + "vehicle_state_fd_window": { + "name": "Front driver window" + }, + "vehicle_state_fp_window": { + "name": "Front passenger window" + }, + "vehicle_state_rd_window": { + "name": "Rear driver window" + }, + "vehicle_state_rp_window": { + "name": "Rear passenger window" } }, "button": { From b51a242fd444f808a7e4712dccd591c60078e646 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 07:56:23 +1000 Subject: [PATCH 738/927] Add install to Tessie update platform (#106352) --- homeassistant/components/tessie/update.py | 23 +++++++++++++++ .../components/tessie/fixtures/vehicles.json | 4 +-- tests/components/tessie/test_update.py | 29 +++++++++++++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 9628b580697..34362c0239c 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -1,6 +1,10 @@ """Update platform for Tessie integration.""" from __future__ import annotations +from typing import Any + +from tessie_api import schedule_software_update + from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -35,6 +39,16 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): """Initialize the Update.""" super().__init__(coordinator, "update") + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + ): + return self._attr_supported_features | UpdateEntityFeature.INSTALL + return self._attr_supported_features + @property def installed_version(self) -> str: """Return the current app version.""" @@ -63,3 +77,12 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): ): return self.get("vehicle_state_software_update_install_perc") return False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.run(schedule_software_update, in_seconds=0) + self.set( + ("vehicle_state_software_update_status", TessieUpdateStatus.INSTALLING) + ) diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index 9d2305a04cd..e150b9e60e7 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -248,8 +248,8 @@ "software_update": { "download_perc": 100, "expected_duration_sec": 2700, - "install_perc": 10, - "status": "installing", + "install_perc": 1, + "status": "available", "version": "2023.44.30.4" }, "speed_limit_mode": { diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 1ade06d3fa7..88bf7c83dd6 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -1,5 +1,12 @@ """Test the Tessie update platform.""" -from homeassistant.const import STATE_ON +from unittest.mock import patch + +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant from .common import setup_platform @@ -14,4 +21,22 @@ async def test_updates(hass: HomeAssistant) -> None: assert len(hass.states.async_all("update")) == 1 - assert hass.states.get("update.test").state == STATE_ON + entity_id = "update.test" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) is False + + with patch( + "homeassistant.components.tessie.update.schedule_software_update" + ) as mock_update: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) == 1 From 244a7bef39c31c3e08e6116d594ef9bf5a3c8830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 12:18:52 -1000 Subject: [PATCH 739/927] Use faster contains check in climate (#106430) --- homeassistant/components/climate/__init__.py | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 5c06b9ddace..f7d168bfa4a 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -329,17 +329,17 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if target_temperature_step := self.target_temperature_step: data[ATTR_TARGET_TEMP_STEP] = target_temperature_step - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_MIN_HUMIDITY] = self.min_humidity data[ATTR_MAX_HUMIDITY] = self.max_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODES] = self.fan_modes - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODES] = self.preset_modes - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODES] = self.swing_modes return data @@ -359,7 +359,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: + if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features: data[ATTR_TEMPERATURE] = show_temp( hass, self.target_temperature, @@ -367,7 +367,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): precision, ) - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + if ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in supported_features: data[ATTR_TARGET_TEMP_HIGH] = show_temp( hass, self.target_temperature_high, temperature_unit, precision ) @@ -378,22 +378,22 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if (current_humidity := self.current_humidity) is not None: data[ATTR_CURRENT_HUMIDITY] = current_humidity - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_HUMIDITY] = self.target_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODE] = self.fan_mode if hvac_action := self.hvac_action: data[ATTR_HVAC_ACTION] = hvac_action - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODE] = self.swing_mode - if supported_features & ClimateEntityFeature.AUX_HEAT: + if ClimateEntityFeature.AUX_HEAT in supported_features: data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF return data From 51a50fc1344d8b25383d9091925d24cd765c644c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 12:19:02 -1000 Subject: [PATCH 740/927] Use faster contains check in fan (#106431) --- homeassistant/components/fan/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index c53be415b8e..1bacc6d8dac 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -400,10 +400,11 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} + supported_features = self.supported_features if ( - self.supported_features & FanEntityFeature.SET_SPEED - or self.supported_features & FanEntityFeature.PRESET_MODE + FanEntityFeature.SET_SPEED in supported_features + or FanEntityFeature.PRESET_MODE in supported_features ): attrs[ATTR_PRESET_MODES] = self.preset_modes @@ -416,20 +417,19 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data: dict[str, float | str | None] = {} supported_features = self.supported_features - if supported_features & FanEntityFeature.DIRECTION: + if FanEntityFeature.DIRECTION in supported_features: data[ATTR_DIRECTION] = self.current_direction - if supported_features & FanEntityFeature.OSCILLATE: + if FanEntityFeature.OSCILLATE in supported_features: data[ATTR_OSCILLATING] = self.oscillating - if supported_features & FanEntityFeature.SET_SPEED: + has_set_speed = FanEntityFeature.SET_SPEED in supported_features + + if has_set_speed: data[ATTR_PERCENTAGE] = self.percentage data[ATTR_PERCENTAGE_STEP] = self.percentage_step - if ( - supported_features & FanEntityFeature.PRESET_MODE - or supported_features & FanEntityFeature.SET_SPEED - ): + if has_set_speed or FanEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode return data From 615cd56f035c901c81bc7592d9504505d120cbbc Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Tue, 26 Dec 2023 17:31:00 -0500 Subject: [PATCH 741/927] Add Support for SleepIQ Foot Warmers (#105931) * Add foot warmer support * Add Tests for foot warmers * Move attr options out of constructor * Change options to lowercase * Update test and translations * Switch back to entity * Update homeassistant/components/sleepiq/strings.json --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sleepiq/const.py | 4 + homeassistant/components/sleepiq/entity.py | 8 ++ homeassistant/components/sleepiq/number.py | 51 +++++++++++- homeassistant/components/sleepiq/select.py | 71 ++++++++++++++-- homeassistant/components/sleepiq/strings.json | 12 +++ tests/components/sleepiq/conftest.py | 18 +++++ tests/components/sleepiq/test_number.py | 38 +++++++++ tests/components/sleepiq/test_select.py | 80 +++++++++++++++++++ 8 files changed, 271 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4eb6148f9b8..4243684cd52 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -11,12 +11,16 @@ ICON_OCCUPIED = "mdi:bed" IS_IN_BED = "is_in_bed" PRESSURE = "pressure" SLEEP_NUMBER = "sleep_number" +FOOT_WARMING_TIMER = "foot_warming_timer" +FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", SLEEP_NUMBER: "SleepNumber", + FOOT_WARMING_TIMER: "Foot Warming Timer", + FOOT_WARMER: "Foot Warmer", } LEFT = "left" diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 38d8eb32051..9a0342aa7ac 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -29,6 +29,14 @@ def device_from_bed(bed: SleepIQBed) -> DeviceInfo: ) +def sleeper_for_side(bed: SleepIQBed, side: str) -> SleepIQSleeper: + """Find the sleeper for a side or the first sleeper.""" + for sleeper in bed.sleepers: + if sleeper.side == side: + return sleeper + return bed.sleepers[0] + + class SleepIQEntity(Entity): """Implementation of a SleepIQ entity.""" diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index b1819d7088d..520e11bb331 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -5,16 +5,23 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast -from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQSleeper +from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQFootWarmer, SleepIQSleeper from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ACTUATOR, DOMAIN, ENTITY_TYPES, FIRMNESS, ICON_OCCUPIED +from .const import ( + ACTUATOR, + DOMAIN, + ENTITY_TYPES, + FIRMNESS, + FOOT_WARMING_TIMER, + ICON_OCCUPIED, +) from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, sleeper_for_side @dataclass(frozen=True) @@ -69,6 +76,21 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: return f"{sleeper.sleeper_id}_{FIRMNESS}" +async def _async_set_foot_warmer_time( + foot_warmer: SleepIQFootWarmer, time: int +) -> None: + foot_warmer.timer = time + + +def _get_foot_warming_name(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + sleeper = sleeper_for_side(bed, foot_warmer.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[FOOT_WARMING_TIMER]}" + + +def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -94,6 +116,18 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_actuator_name, get_unique_id_fn=_get_actuator_unique_id, ), + FOOT_WARMING_TIMER: SleepIQNumberEntityDescription( + key=FOOT_WARMING_TIMER, + native_min_value=30, + native_max_value=360, + native_step=30, + name=ENTITY_TYPES[FOOT_WARMING_TIMER], + icon="mdi:timer", + value_fn=lambda foot_warmer: foot_warmer.timer, + set_value_fn=_async_set_foot_warmer_time, + get_name_fn=_get_foot_warming_name, + get_unique_id_fn=_get_foot_warming_unique_id, + ), } @@ -125,6 +159,15 @@ async def async_setup_entry( NUMBER_DESCRIPTIONS[ACTUATOR], ) ) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQNumberEntity( + data.data_coordinator, + bed, + foot_warmer, + NUMBER_DESCRIPTIONS[FOOT_WARMING_TIMER], + ) + ) async_add_entities(entities) @@ -148,6 +191,8 @@ class SleepIQNumberEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Number self._attr_name = description.get_name_fn(bed, device) self._attr_unique_id = description.get_unique_id_fn(bed, device) + if description.icon: + self._attr_icon = description.icon super().__init__(coordinator, bed) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1609dc2e116..df8d854c9da 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,16 +1,22 @@ """Support for SleepIQ foundation preset selection.""" from __future__ import annotations -from asyncsleepiq import Side, SleepIQBed, SleepIQPreset +from asyncsleepiq import ( + FootWarmingTemps, + Side, + SleepIQBed, + SleepIQFootWarmer, + SleepIQPreset, +) from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( @@ -20,11 +26,17 @@ async def async_setup_entry( ) -> None: """Set up the SleepIQ foundation preset select entities.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - SleepIQSelectEntity(data.data_coordinator, bed, preset) - for bed in data.client.beds.values() - for preset in bed.foundation.presets - ) + entities: list[SleepIQBedEntity] = [] + for bed in data.client.beds.values(): + for preset in bed.foundation.presets: + entities.append(SleepIQSelectEntity(data.data_coordinator, bed, preset)) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQFootWarmingTempSelectEntity( + data.data_coordinator, bed, foot_warmer + ) + ) + async_add_entities(entities) class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity): @@ -59,3 +71,46 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select await self.preset.set_preset(option) self._attr_current_option = option self.async_write_ha_state() + + +class SleepIQFootWarmingTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ foot warming temperature select entity.""" + + _attr_icon = "mdi:heat-wave" + _attr_options = [e.name.lower() for e in FootWarmingTemps] + _attr_translation_key = "foot_warmer_temp" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + foot_warmer: SleepIQFootWarmer, + ) -> None: + """Initialize the select entity.""" + self.foot_warmer = foot_warmer + sleeper = sleeper_for_side(bed, foot_warmer.side) + super().__init__(coordinator, bed, sleeper, FOOT_WARMER) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._attr_current_option = FootWarmingTemps( + self.foot_warmer.temperature + ).name.lower() + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = FootWarmingTemps[option.upper()] + timer = self.foot_warmer.timer or 120 + + if temperature == 0: + await self.foot_warmer.turn_off() + else: + await self.foot_warmer.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 7a9a4c58464..bdafbfb6c77 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -23,5 +23,17 @@ } } } + }, + "entity": { + "select": { + "foot_warmer_temp": { + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } } } diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 05104546f0d..58718edcafb 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -6,9 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, SleepIQPreset, @@ -34,6 +36,7 @@ SLEEPER_L_NAME_LOWER = SLEEPER_L_NAME.lower().replace(" ", "_") SLEEPER_R_NAME_LOWER = SLEEPER_R_NAME.lower().replace(" ", "_") PRESET_L_STATE = "Watch TV" PRESET_R_STATE = "Flat" +FOOT_WARM_TIME = 120 SLEEPIQ_CONFIG = { CONF_USERNAME: "user@email.com", @@ -86,6 +89,7 @@ def mock_bed() -> MagicMock: light_2.is_on = False bed.foundation.lights = [light_1, light_2] + bed.foundation.foot_warmers = [] return bed @@ -120,6 +124,8 @@ def mock_asyncsleepiq_single_foundation( preset.side = Side.NONE preset.side_full = "Right" preset.options = BED_PRESETS + + mock_bed.foundation.foot_warmers = [] yield client @@ -166,6 +172,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]: preset_r.side_full = "Right" preset_r.options = BED_PRESETS + foot_warmer_l = create_autospec(SleepIQFootWarmer) + foot_warmer_r = create_autospec(SleepIQFootWarmer) + mock_bed.foundation.foot_warmers = [foot_warmer_l, foot_warmer_r] + + foot_warmer_l.side = Side.LEFT + foot_warmer_l.timer = FOOT_WARM_TIME + foot_warmer_l.temperature = FootWarmingTemps.MEDIUM + + foot_warmer_r.side = Side.RIGHT + foot_warmer_r.timer = FOOT_WARM_TIME + foot_warmer_r.temperature = FootWarmingTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index fe03a4d9c3f..4676cf94174 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -156,3 +156,41 @@ async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].foundation.actuators[ 0 ].set_position.assert_called_with(42) + + +async def test_foot_warmer_timer(hass: HomeAssistant, mock_asyncsleepiq) -> None: + """Test the SleepIQ foot warmer number values for a bed with two sides.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert state.state == "120.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 30 + assert state.attributes.get(ATTR_MAX) == 360 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warming Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_foot_warming_timer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer", + ATTR_VALUE: 300, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index d0e2a0e828d..c4ec3896bd7 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -1,6 +1,8 @@ """Tests for the SleepIQ select platform.""" from unittest.mock import MagicMock +from asyncsleepiq import FootWarmingTemps + from homeassistant.components.select import DOMAIN, SERVICE_SELECT_OPTION from homeassistant.const import ( ATTR_ENTITY_ID, @@ -15,8 +17,15 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, + SLEEPER_L_ID, + SLEEPER_L_NAME, + SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, + SLEEPER_R_NAME, + SLEEPER_R_NAME_LOWER, setup_platform, ) @@ -115,3 +124,74 @@ async def test_single_foundation_preset( mock_asyncsleepiq_single_foundation.beds[BED_ID].foundation.presets[ 0 ].set_preset.assert_called_with("Zero G") + + +async def test_foot_warmer(hass: HomeAssistant, mock_asyncsleepiq: MagicMock) -> None: + """Test the SleepIQ select entity for foot warmers.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.MEDIUM.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) From 9dde42a02300d04a0247cd366074672b51be5bc7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 08:31:25 +1000 Subject: [PATCH 742/927] Handle Auto Fan and MyFan in Advantage Air (#95594) * Decouple climate from MyFan * Add tests * Prepare for auto change * Handle both modes * Fix import * Remove reference to FAN map * Correct auto fan logic * Remove multiline ternary operator Co-authored-by: G Johansson * Fix coverage * fix tests * ruff * Test auto fan mode with snapshot * add more testing * Add switch testing * Fix a bug caught by new tests * Remove ineffective snapshot tests * Readd snapshots but use args --------- Co-authored-by: G Johansson --- .../components/advantage_air/climate.py | 44 ++++++--------- .../components/advantage_air/const.py | 1 + .../components/advantage_air/switch.py | 29 ++++++++++ .../advantage_air/snapshots/test_climate.ambr | 55 +++++++++++++++++++ .../advantage_air/snapshots/test_switch.ambr | 33 +++++++++++ .../components/advantage_air/test_climate.py | 45 +++++++++------ tests/components/advantage_air/test_switch.py | 36 +++++++++++- 7 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 tests/components/advantage_air/snapshots/test_climate.ambr create mode 100644 tests/components/advantage_air/snapshots/test_switch.ambr diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 8244472f2b4..a488ba8b362 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, @@ -39,16 +40,6 @@ ADVANTAGE_AIR_HVAC_MODES = { } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -ADVANTAGE_AIR_FAN_MODES = { - "autoAA": FAN_AUTO, - "low": FAN_LOW, - "medium": FAN_MEDIUM, - "high": FAN_HIGH, -} -HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} -FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} - -ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" ADVANTAGE_AIR_MYZONE = "MyZone" ADVANTAGE_AIR_MYAUTO = "MyAuto" ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" @@ -56,6 +47,7 @@ ADVANTAGE_AIR_MYTEMP = "MyTemp" ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" +ADVANTAGE_AIR_MYFAN = "autoAA" PARALLEL_UPDATES = 0 @@ -85,27 +77,25 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" - _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None - _attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] - - _attr_supported_features = ClimateEntityFeature.FAN_MODE - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] # Set supported features and HVAC modes based on current operating mode if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto @@ -118,10 +108,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): # MyZone self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - # Add "ezfan" mode if supported - if self._ac.get(ADVANTAGE_AIR_AUTOFAN): - self._attr_fan_modes += [FAN_AUTO] - @property def current_temperature(self) -> float | None: """Return the selected zones current temperature.""" @@ -151,7 +137,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return the current fan modes.""" - return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + return FAN_AUTO if self._ac["fan"] == ADVANTAGE_AIR_MYFAN else self._ac["fan"] @property def target_temperature_high(self) -> float | None: @@ -189,7 +175,11 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" - await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)}) + if fan_mode == FAN_AUTO and self._ac.get(ADVANTAGE_AIR_AUTOFAN_ENABLED): + mode = ADVANTAGE_AIR_MYFAN + else: + mode = fan_mode + await self.async_update_ac({"fan": mode}) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 5c044481ca0..80ce9b6eaa1 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -5,3 +5,4 @@ ADVANTAGE_AIR_STATE_OPEN = "open" ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" +ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 7234ca36305..abc9b795d43 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN, @@ -29,6 +30,8 @@ async def async_setup_entry( for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": entities.append(AdvantageAirFreshAir(instance, ac_key)) + if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: + entities.append(AdvantageAirMyFan(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] == 8: # 8 = Other relay @@ -62,6 +65,32 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}) +class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage Air MyFan control.""" + + _attr_icon = "mdi:fan-auto" + _attr_name = "MyFan" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air MyFan control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-myfan" + + @property + def is_on(self) -> bool: + """Return the MyFan status.""" + return self._ac[ADVANTAGE_AIR_AUTOFAN_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn MyFan on.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn MyFan off.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr new file mode 100644 index 00000000000..9e21d0ede17 --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_climate_myauto_main[climate.myauto-fanmode] + dict({ + 'ac3': dict({ + 'info': dict({ + 'fan': 'autoAA', + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto-settemp] + dict({ + 'ac3': dict({ + 'info': dict({ + 'myAutoCoolTargetTemp': 23.0, + 'myAutoHeatTargetTemp': 21.0, + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'medium', + 'high', + 'auto', + ]), + 'friendly_name': 'myauto', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 16, + 'supported_features': , + 'target_temp_high': 24, + 'target_temp_low': 20, + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.myauto', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/advantage_air/snapshots/test_switch.ambr b/tests/components/advantage_air/snapshots/test_switch.ambr new file mode 100644 index 00000000000..2060c0798ed --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_switch.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnoff] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': False, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnon] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': True, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'myzone MyFan', + 'icon': 'mdi:fan-auto', + }), + 'context': , + 'entity_id': 'switch.myzone_myfan', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index ba97644501f..704e25c0473 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from advantage_air import ApiError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -14,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, FAN_LOW, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, @@ -27,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import add_mock_config, patch_update +from . import add_mock_config async def test_climate_myzone_main( @@ -182,6 +184,7 @@ async def test_climate_myauto_main( entity_registry: er.EntityRegistry, mock_get: AsyncMock, mock_update: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test climate platform zone entity.""" @@ -189,27 +192,35 @@ async def test_climate_myauto_main( # Test MyAuto Climate Entity entity_id = "climate.myauto" - state = hass.states.get(entity_id) - assert state - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 + assert hass.states.get(entity_id) == snapshot(name=entity_id) entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac3" - with patch_update() as mock_update: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: [entity_id], - ATTR_TARGET_TEMP_LOW: 21, - ATTR_TARGET_TEMP_HIGH: 23, - }, - blocking=True, - ) - mock_update.assert_called_once() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_LOW: 21, + ATTR_TARGET_TEMP_HIGH: 23, + }, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-settemp") + mock_update.reset_mock() + + # Test AutoFanMode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-fanmode") async def test_climate_async_failed_update( diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index a703f7edefd..4977a4cc31f 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,8 +1,9 @@ """Test the Advantage Air Switch Platform.""" - from unittest.mock import AsyncMock +from syrupy import SnapshotAssertion + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -20,12 +21,15 @@ async def test_cover_async_setup_entry( entity_registry: er.EntityRegistry, mock_get: AsyncMock, mock_update: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test switch platform.""" await add_mock_config(hass) - # Test Switch Entity + registry = er.async_get(hass) + + # Test Fresh Air Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state @@ -51,6 +55,34 @@ async def test_cover_async_setup_entry( blocking=True, ) mock_update.assert_called_once() + mock_update.reset_mock() + + # Test MyFan Switch Entity + entity_id = "switch.myzone_myfan" + assert hass.states.get(entity_id) == snapshot(name=entity_id) + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac1-myfan" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnon") + mock_update.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnoff") async def test_things_switch( From 4b19c28ad9bd5728e6fb538df55b4a1709df2c97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 13:18:22 -1000 Subject: [PATCH 743/927] Use faster contains check in media_player (#106434) --- .../components/media_player/__init__.py | 44 +++++++++---------- .../components/universal/test_media_player.py | 2 +- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 977c7cbf0f3..a4439c9c68e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -905,87 +905,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY) + return MediaPlayerEntityFeature.PLAY in self.supported_features @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PAUSE) + return MediaPlayerEntityFeature.PAUSE in self.supported_features @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.STOP) + return MediaPlayerEntityFeature.STOP in self.supported_features @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SEEK) + return MediaPlayerEntityFeature.SEEK in self.supported_features @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_SET) + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_MUTE) + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PREVIOUS_TRACK) + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.NEXT_TRACK) + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY_MEDIA) + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE) + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return bool( - self.supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) + return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.CLEAR_PLAYLIST) + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SHUFFLE_SET) + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.GROUPING) + return MediaPlayerEntityFeature.GROUPING in self.supported_features async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1014,7 +1012,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1032,7 +1030,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1077,14 +1075,14 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data: dict[str, Any] = {} supported_features = self.supported_features - if supported_features & MediaPlayerEntityFeature.SELECT_SOURCE and ( + if ( source_list := self.source_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOURCE in supported_features: data[ATTR_INPUT_SOURCE_LIST] = source_list - if supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE and ( + if ( sound_mode_list := self.sound_mode_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOUND_MODE in supported_features: data[ATTR_SOUND_MODE_LIST] = sound_mode_list return data @@ -1282,7 +1280,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if not player.supported_features & MediaPlayerEntityFeature.BROWSE_MEDIA: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index e31cab59358..60196e6fe24 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -159,7 +159,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): @property def supported_features(self): """Flag media player features that are supported.""" - return self._supported_features + return MediaPlayerEntityFeature(self._supported_features) @property def media_image_url(self): From 2fe982c7f3dc774244ec2a1f801dd0f4dd54c34a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 13:19:44 +1000 Subject: [PATCH 744/927] Handle failed updates in Tessie (#106450) --- homeassistant/components/tessie/entity.py | 6 +++++- tests/components/tessie/common.py | 1 + tests/components/tessie/test_cover.py | 23 ++++++++++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index ecd7f863542..fc6e8939da9 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -56,7 +56,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): ) -> None: """Run a tessie_api function and handle exceptions.""" try: - await func( + response = await func( session=self.coordinator.session, vin=self.vin, api_key=self.coordinator.api_key, @@ -64,6 +64,10 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): ) except ClientResponseError as e: raise HomeAssistantError from e + if response["result"] is False: + raise HomeAssistantError( + response.get("reason"), "An unknown issue occurred" + ) def set(self, *args: Any) -> None: """Set a value in coordinator data.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index a26f4becf78..ae80526e5d9 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -16,6 +16,7 @@ TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) TEST_RESPONSE = {"result": True} +TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"} TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} TESSIE_URL = "https://api.tessie.com/" diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index be75b6df60a..426b8ab1696 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform +from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform async def test_window(hass: HomeAssistant) -> None: @@ -110,3 +110,24 @@ async def test_errors(hass: HomeAssistant) -> None: ) mock_set.assert_called_once() assert error.from_exception == ERROR_UNKNOWN + + +async def test_response_error(hass: HomeAssistant) -> None: + """Tests response errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + return_value=TEST_RESPONSE_ERROR, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert str(error) == TEST_RESPONSE_ERROR["reason"] From 91aea843fc8da0572eb997c1a814ce43ed3718df Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 15:56:09 +1000 Subject: [PATCH 745/927] Move Trunks from Button to Cover in Tessie (#106448) * Move trunks from buttons to covers * Add tests * Cleanup snapshot * Use Constants * StrEnum to IntEnum --- homeassistant/components/tessie/button.py | 8 -- homeassistant/components/tessie/const.py | 9 +- homeassistant/components/tessie/cover.py | 83 +++++++++++--- homeassistant/components/tessie/strings.json | 8 +- .../tessie/snapshots/test_cover.ambr | 57 +++++++++ tests/components/tessie/test_cover.py | 108 +++++++----------- 6 files changed, 181 insertions(+), 92 deletions(-) create mode 100644 tests/components/tessie/snapshots/test_cover.ambr diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index df918d057a2..817bdb3a87c 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -9,8 +9,6 @@ from tessie_api import ( enable_keyless_driving, flash_lights, honk, - open_close_rear_trunk, - open_front_trunk, trigger_homelink, wake, ) @@ -49,12 +47,6 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( TessieButtonEntityDescription( key="boombox", func=lambda: boombox, icon="mdi:volume-high" ), - TessieButtonEntityDescription( - key="frunk", func=lambda: open_front_trunk, icon="mdi:car" - ), - TessieButtonEntityDescription( - key="trunk", func=lambda: open_close_rear_trunk, icon="mdi:car-back" - ), ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index a6ff7932fa4..2ba4e514579 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -1,7 +1,7 @@ """Constants used by Tessie integration.""" from __future__ import annotations -from enum import StrEnum +from enum import IntEnum, StrEnum DOMAIN = "tessie" @@ -46,3 +46,10 @@ class TessieUpdateStatus(StrEnum): INSTALLING = "installing" WIFI_WAIT = "downloading_wifi_wait" SCHEDULED = "scheduled" + + +class TessieCoverStates(IntEnum): + """Tessie Cover states.""" + + CLOSED = 0 + OPEN = 1 diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index dddda068d61..6b4393fce1f 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -6,6 +6,8 @@ from typing import Any from tessie_api import ( close_charge_port, close_windows, + open_close_rear_trunk, + open_front_trunk, open_unlock_charge_port, vent_windows, ) @@ -19,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TessieCoverStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -31,10 +33,12 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - Entity(vehicle.state_coordinator) - for Entity in ( + klass(vehicle.state_coordinator) + for klass in ( TessieWindowEntity, TessieChargePortEntity, + TessieFrontTrunkEntity, + TessieRearTrunkEntity, ) for vehicle in data ) @@ -54,30 +58,30 @@ class TessieWindowEntity(TessieEntity, CoverEntity): def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" return ( - self.get("vehicle_state_fd_window") == 0 - and self.get("vehicle_state_fp_window") == 0 - and self.get("vehicle_state_rd_window") == 0 - and self.get("vehicle_state_rp_window") == 0 + self.get("vehicle_state_fd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_fp_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rp_window") == TessieCoverStates.CLOSED ) async def async_open_cover(self, **kwargs: Any) -> None: """Open windows.""" await self.run(vent_windows) self.set( - ("vehicle_state_fd_window", 1), - ("vehicle_state_fp_window", 1), - ("vehicle_state_rd_window", 1), - ("vehicle_state_rp_window", 1), + ("vehicle_state_fd_window", TessieCoverStates.OPEN), + ("vehicle_state_fp_window", TessieCoverStates.OPEN), + ("vehicle_state_rd_window", TessieCoverStates.OPEN), + ("vehicle_state_rp_window", TessieCoverStates.OPEN), ) async def async_close_cover(self, **kwargs: Any) -> None: """Close windows.""" await self.run(close_windows) self.set( - ("vehicle_state_fd_window", 0), - ("vehicle_state_fp_window", 0), - ("vehicle_state_rd_window", 0), - ("vehicle_state_rp_window", 0), + ("vehicle_state_fd_window", TessieCoverStates.CLOSED), + ("vehicle_state_fp_window", TessieCoverStates.CLOSED), + ("vehicle_state_rd_window", TessieCoverStates.CLOSED), + ("vehicle_state_rp_window", TessieCoverStates.CLOSED), ) @@ -105,3 +109,52 @@ class TessieChargePortEntity(TessieEntity, CoverEntity): """Close windows.""" await self.run(close_charge_port) self.set((self.key, False)) + + +class TessieFrontTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_ft") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open front trunk.""" + await self.run(open_front_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + +class TessieRearTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_rt") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open rear trunk.""" + if self._value == TessieCoverStates.CLOSED: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close rear trunk.""" + if self._value == TessieCoverStates.OPEN: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.CLOSED)) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 473f67888cd..a72b65591e2 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -123,7 +123,9 @@ }, "charge_state_charge_port_door_open": { "name": "Charge port door" - } + }, + "vehicle_state_ft": { "name": "Frunk" }, + "vehicle_state_rt": { "name": "Trunk" } }, "select": { "climate_state_seat_heater_left": { @@ -267,9 +269,7 @@ "honk": { "name": "Honk horn" }, "trigger_homelink": { "name": "Homelink" }, "enable_keyless_driving": { "name": "Keyless driving" }, - "boombox": { "name": "Play fart" }, - "frunk": { "name": "Open frunk" }, - "trunk": { "name": "Open/Close trunk" } + "boombox": { "name": "Play fart" } }, "switch": { "charge_state_charge_enable_request": { diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ae5e95be68d --- /dev/null +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_covers[cover.test_charge_port_door-open_unlock_charge_port-close_charge_port][cover.test_charge_port_door] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_covers[cover.test_frunk-open_front_trunk-False][cover.test_frunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_trunk-open_close_rear_trunk-open_close_rear_trunk][cover.test_trunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_vent_windows-vent_windows-close_windows][cover.test_vent_windows] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Vent windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_vent_windows', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 426b8ab1696..713108b962a 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -17,78 +18,57 @@ from homeassistant.exceptions import HomeAssistantError from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform -async def test_window(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("entity_id", "openfunc", "closefunc"), + [ + ("cover.test_vent_windows", "vent_windows", "close_windows"), + ("cover.test_charge_port_door", "open_unlock_charge_port", "close_charge_port"), + ("cover.test_frunk", "open_front_trunk", False), + ("cover.test_trunk", "open_close_rear_trunk", "open_close_rear_trunk"), + ], +) +async def test_covers( + hass: HomeAssistant, + entity_id: str, + openfunc: str, + closefunc: str, + snapshot: SnapshotAssertion, +) -> None: """Tests that the window cover entity is correct.""" await setup_platform(hass) - entity_id = "cover.test_vent_windows" - assert hass.states.get(entity_id).state == STATE_CLOSED + assert hass.states.get(entity_id) == snapshot(name=entity_id) # Test open windows - with patch( - "homeassistant.components.tessie.cover.vent_windows", - return_value=TEST_RESPONSE, - ) as mock_set: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_set.assert_called_once() - assert hass.states.get(entity_id).state == STATE_OPEN + if openfunc: + with patch( + f"homeassistant.components.tessie.cover.{openfunc}", + return_value=TEST_RESPONSE, + ) as mock_open: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_open.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN # Test close windows - with patch( - "homeassistant.components.tessie.cover.close_windows", - return_value=TEST_RESPONSE, - ) as mock_set: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_set.assert_called_once() - assert hass.states.get(entity_id).state == STATE_CLOSED - - -async def test_charge_port(hass: HomeAssistant) -> None: - """Tests that the charge port cover entity is correct.""" - - await setup_platform(hass) - - entity_id = "cover.test_charge_port_door" - assert hass.states.get(entity_id).state == STATE_OPEN - - # Test close charge port - with patch( - "homeassistant.components.tessie.cover.close_charge_port", - return_value=TEST_RESPONSE, - ) as mock_set: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_set.assert_called_once() - assert hass.states.get(entity_id).state == STATE_CLOSED - - # Test open charge port - with patch( - "homeassistant.components.tessie.cover.open_unlock_charge_port", - return_value=TEST_RESPONSE, - ) as mock_set: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_set.assert_called_once() - assert hass.states.get(entity_id).state == STATE_OPEN + if closefunc: + with patch( + f"homeassistant.components.tessie.cover.{closefunc}", + return_value=TEST_RESPONSE, + ) as mock_close: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_close.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED async def test_errors(hass: HomeAssistant) -> None: From 2afe3364cae9786797780fc036898ea8a0793a15 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 16:56:23 +1000 Subject: [PATCH 746/927] Add names to all Tessie entities (#106267) * Add name back to device tracker and lock * Add name to media player * Add name to update * Update snapshot * Keep none name on Update * Re-add update --- .../components/tessie/device_tracker.py | 1 - homeassistant/components/tessie/lock.py | 2 -- homeassistant/components/tessie/media_player.py | 1 - homeassistant/components/tessie/strings.json | 16 ++++++++++++++++ homeassistant/components/tessie/update.py | 1 - .../tessie/snapshots/test_media_player.ambr | 8 ++++---- tests/components/tessie/test_device_tracker.py | 2 +- tests/components/tessie/test_lock.py | 2 +- tests/components/tessie/test_media_player.py | 4 ++-- tests/components/tessie/test_update.py | 2 +- 10 files changed, 25 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index 2652a6247c8..9b1ddfcfe4f 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -48,7 +48,6 @@ class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): class TessieDeviceTrackerLocationEntity(TessieDeviceTrackerEntity): """Vehicle Location Device Tracker Class.""" - _attr_name = None key = "location" @property diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 25b9de4b579..e8fb8930bbc 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -27,8 +27,6 @@ async def async_setup_entry( class TessieLockEntity(TessieEntity, LockEntity): """Lock entity for current charge.""" - _attr_name = None - def __init__( self, coordinator: TessieStateUpdateCoordinator, diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 544290de093..c4392e1de1d 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -33,7 +33,6 @@ async def async_setup_entry( class TessieMediaEntity(TessieEntity, MediaPlayerEntity): """Vehicle Location Media Class.""" - _attr_name = None _attr_device_class = MediaPlayerDeviceClass.SPEAKER def __init__( diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index a72b65591e2..7cf511c125c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -24,6 +24,7 @@ "entity": { "device_tracker": { "location": { + "name": "Location", "state_attributes": { "heading": { "name": "Heading" @@ -52,6 +53,16 @@ } } }, + "lock": { + "vehicle_state_locked": { + "name": "[%key:component::lock::title%]" + } + }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, "sensor": { "charge_state_usable_battery_level": { "name": "Battery level" @@ -298,6 +309,11 @@ "vehicle_state_speed_limit_mode_current_limit_mph": { "name": "Speed limit" } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } } } } diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 34362c0239c..457978f232f 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -30,7 +30,6 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): """Tessie Updates entity.""" _attr_supported_features = UpdateEntityFeature.PROGRESS - _attr_name = None def __init__( self, diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index 8dc07797d6c..e4c7f37c4ce 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -3,12 +3,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'friendly_name': 'Test', + 'friendly_name': 'Test Media player', 'supported_features': , 'volume_level': 0.22580323309042688, }), 'context': , - 'entity_id': 'media_player.test', + 'entity_id': 'media_player.test_media_player', 'last_changed': , 'last_updated': , 'state': 'idle', @@ -18,12 +18,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'friendly_name': 'Test', + 'friendly_name': 'Test Media player', 'supported_features': , 'volume_level': 0.22580323309042688, }), 'context': , - 'entity_id': 'media_player.test', + 'entity_id': 'media_player.test_media_player', 'last_changed': , 'last_updated': , 'state': 'idle', diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index 1ea4ee839be..d737b02b40e 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -19,7 +19,7 @@ async def test_device_tracker(hass: HomeAssistant) -> None: assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 2 - entity_id = "device_tracker.test" + entity_id = "device_tracker.test_location" state = hass.states.get(entity_id) assert state.attributes.get(ATTR_LATITUDE) == STATES["drive_state"]["latitude"] assert state.attributes.get(ATTR_LONGITUDE) == STATES["drive_state"]["longitude"] diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 38601de52c9..93a1151a850 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -22,7 +22,7 @@ async def test_locks(hass: HomeAssistant) -> None: assert len(hass.states.async_all("lock")) == 1 - entity_id = "lock.test" + entity_id = "lock.test_lock" assert ( hass.states.get(entity_id).state == STATE_LOCKED diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py index 8e3e339b560..f658fe28acd 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -35,12 +35,12 @@ async def test_media_player_idle( assert len(hass.states.async_all("media_player")) == 1 - state = hass.states.get("media_player.test") + state = hass.states.get("media_player.test_media_player") assert state == snapshot # Trigger coordinator refresh since it has a different fixture. freezer.tick(WAIT) async_fire_time_changed(hass) - state = hass.states.get("media_player.test") + state = hass.states.get("media_player.test_media_player") assert state == snapshot diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 88bf7c83dd6..182acdf17ff 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -21,7 +21,7 @@ async def test_updates(hass: HomeAssistant) -> None: assert len(hass.states.async_all("update")) == 1 - entity_id = "update.test" + entity_id = "update.test_update" state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes.get(ATTR_IN_PROGRESS) is False From b08268da31b89ab778f7b6058764eb824615e954 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 27 Dec 2023 08:42:57 +0100 Subject: [PATCH 747/927] Deprecate deprecated core constants (#106456) --- homeassistant/core.py | 39 ++++++++++++++++++++++++++++++++++++--- tests/test_core.py | 22 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7f0883ca880..da49f30d58a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -147,9 +147,42 @@ class ConfigSource(enum.StrEnum): # SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead -SOURCE_DISCOVERED = ConfigSource.DISCOVERED.value -SOURCE_STORAGE = ConfigSource.STORAGE.value -SOURCE_YAML = ConfigSource.YAML.value +_DEPRECATED_SOURCE_DISCOVERED = (ConfigSource.DISCOVERED, "2025.1") +_DEPRECATED_SOURCE_STORAGE = (ConfigSource.STORAGE, "2025.1") +_DEPRECATED_SOURCE_YAML = (ConfigSource.YAML, "2025.1") + + +# Can be removed if no deprecated constant are in this module anymore +def __getattr__(name: str) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_globals = globals() + if f"_DEPRECATED_{name}" not in module_globals: + raise AttributeError(f"Module {__name__} has no attribute {name!r}") + + # Avoid circular import + from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel + check_if_deprecated_constant, + ) + + return check_if_deprecated_constant(name, module_globals) + + +# Can be removed if no deprecated constant are in this module anymore +def __dir__() -> list[str]: + """Return dir() with deprecated constants.""" + # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle + module_globals = globals() + + return list(module_globals) + [ + name.removeprefix("_DEPRECATED_") + for name in module_globals + if name.startswith("_DEPRECATED_") + ] + # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 diff --git a/tests/test_core.py b/tests/test_core.py index ce1767f2755..5f5be1b05db 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -58,7 +58,11 @@ import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import METRIC_SYSTEM -from .common import async_capture_events, async_mock_service +from .common import ( + async_capture_events, + async_mock_service, + import_and_test_deprecated_constant_enum, +) PST = dt_util.get_time_zone("America/Los_Angeles") @@ -2621,3 +2625,19 @@ async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: cancel() await hass.async_stop() assert not evt.is_set() + + +@pytest.mark.parametrize( + ("enum"), + [ + ha.ConfigSource.DISCOVERED, + ha.ConfigSource.YAML, + ha.ConfigSource.STORAGE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: ha.ConfigSource, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") From e801413c730c8d1b3d9cc21d502eaf3ae4469b7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:45:07 -1000 Subject: [PATCH 748/927] Use faster contains check in vacuum (#106437) --- homeassistant/components/vacuum/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 04265dcf63d..3ff29ec4e47 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -281,7 +281,7 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> Mapping[str, Any] | None: """Return capability attributes.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in self.supported_features: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -289,12 +289,13 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} + supported_features = self.supported_features - if self.supported_features & VacuumEntityFeature.BATTERY: + if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level data[ATTR_BATTERY_ICON] = self.battery_icon - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in supported_features: data[ATTR_FAN_SPEED] = self.fan_speed return data @@ -470,7 +471,7 @@ class VacuumEntity( """Return the state attributes of the vacuum cleaner.""" data = super().state_attributes - if self.supported_features & VacuumEntityFeature.STATUS: + if VacuumEntityFeature.STATUS in self.supported_features: data[ATTR_STATUS] = self.status return data From 6dbfd70e300dc0c1d6af066e888c57fa78cebdd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:45:25 -1000 Subject: [PATCH 749/927] Use faster contains check in remote (#106435) --- homeassistant/components/remote/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 5d076c0768f..8c3d094710e 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -214,7 +214,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if not self.supported_features & RemoteEntityFeature.ACTIVITY: + if RemoteEntityFeature.ACTIVITY not in self.supported_features: return None return { From 7a2a99db2e187bbad53d980814845e95e9a019c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:45:51 -1000 Subject: [PATCH 750/927] Use faster contains check in update (#106436) --- homeassistant/components/update/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index d4ceff9dc24..43a2a3e785f 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -140,7 +140,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and not entity.supported_features & UpdateEntityFeature.SPECIFIC_VERSION + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -149,7 +149,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and not entity.supported_features & UpdateEntityFeature.BACKUP: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. @@ -263,7 +263,7 @@ class UpdateEntity( return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if self.supported_features & UpdateEntityFeature.INSTALL: + if UpdateEntityFeature.INSTALL in self.supported_features: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -408,7 +408,7 @@ class UpdateEntity( # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS in self.supported_features: in_progress = self.in_progress else: in_progress = self.__in_progress @@ -444,7 +444,7 @@ class UpdateEntity( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if not self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS not in self.supported_features: self.__in_progress = True self.async_write_ha_state() @@ -490,7 +490,7 @@ async def websocket_release_notes( ) return - if not entity.supported_features & UpdateEntityFeature.RELEASE_NOTES: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_SUPPORTED, From 9b864e8130b566285ba6f390c99252e2d8ea8229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:46:13 -1000 Subject: [PATCH 751/927] Use faster contains check in humidifier (#106433) --- homeassistant/components/humidifier/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 79aaff7c06a..821cc8c4f37 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -185,7 +185,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT ATTR_MAX_HUMIDITY: self.max_humidity, } - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_AVAILABLE_MODES] = self.available_modes return data @@ -214,7 +214,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_MODE] = self.mode return data From 2cc6fd1afb5cac2cb9be7edb72c639e174f6efba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:46:36 -1000 Subject: [PATCH 752/927] Add attr caching support to the geo_location platform (#106432) --- .../components/geo_location/__init__.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index af64443ca28..c5e91d32b20 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE @@ -16,6 +16,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_DISTANCE = "distance" @@ -51,7 +57,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class GeolocationEvent(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "source", + "distance", + "latitude", + "longitude", +} + + +class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for an external event with an associated geolocation.""" # Entity Properties @@ -68,22 +82,22 @@ class GeolocationEvent(Entity): return round(self.distance, 1) return None - @property + @cached_property def source(self) -> str: """Return source value of this external event.""" return self._attr_source - @property + @cached_property def distance(self) -> float | None: """Return distance value of this external event.""" return self._attr_distance - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of this external event.""" return self._attr_latitude - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of this external event.""" return self._attr_longitude From 59a01da0eddd2bbb064211aae20b0107ad1f3736 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 27 Dec 2023 08:48:07 +0100 Subject: [PATCH 753/927] Improve cloud tts tests (#106427) --- tests/components/cloud/conftest.py | 5 + tests/components/cloud/test_tts.py | 190 ++++++++++++++++++++++------- 2 files changed, 148 insertions(+), 47 deletions(-) diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 6eaca4906c0..ef8cb037cdb 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -152,6 +152,11 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + @pytest.fixture(autouse=True) def mock_user_data(): """Mock os module.""" diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index ba88ae2af2d..dc32747182d 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,23 +1,35 @@ """Tests for cloud tts.""" -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from hass_nabucasa import voice +from hass_nabucasa.voice import MAP_VOICE, VoiceError import pytest import voluptuous as vol -from homeassistant.components.cloud import const, tts +from homeassistant.components.cloud import DOMAIN, const, tts +from homeassistant.components.tts import DOMAIN as TTS_DOMAIN +from homeassistant.components.tts.helper import get_engine_instance +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator -@pytest.fixture -def cloud_with_prefs(cloud_prefs): - """Return a cloud mock with prefs.""" - return Mock(client=Mock(prefs=cloud_prefs)) +@pytest.fixture(autouse=True) +async def internal_url_mock(hass: HomeAssistant) -> None: + """Mock internal URL of the instance.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) def test_default_exists() -> None: """Test our default language exists.""" - assert const.DEFAULT_TTS_DEFAULT_VOICE in voice.MAP_VOICE + assert const.DEFAULT_TTS_DEFAULT_VOICE in MAP_VOICE def test_schema() -> None: @@ -42,54 +54,138 @@ def test_schema() -> None: tts.PLATFORM_SCHEMA({"platform": "cloud"}) +@pytest.mark.parametrize( + ("engine_id", "platform_config"), + [ + ( + DOMAIN, + None, + ), + ( + DOMAIN, + { + "platform": DOMAIN, + "service_name": "yaml", + "language": "fr-FR", + "gender": "female", + }, + ), + ], +) async def test_prefs_default_voice( - hass: HomeAssistant, cloud_with_prefs, cloud_prefs + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + engine_id: str, + platform_config: dict[str, Any] | None, ) -> None: """Test cloud provider uses the preferences.""" - assert cloud_prefs.tts_default_voice == ("en-US", "female") - - tts_info = {"platform_loaded": Mock()} - provider_pref = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - provider_conf = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), - {"language": "fr-FR", "gender": "female"}, - None, - ) - - assert provider_pref.default_language == "en-US" - assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} - - await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male")) + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, TTS_DOMAIN, {TTS_DOMAIN: platform_config}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert provider_pref.default_language == "nl-NL" - assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} + assert cloud.client.prefs.tts_default_voice == ("en-US", "female") + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + engine = get_engine_instance(hass, engine_id) + + assert engine is not None + # The platform config provider will be overridden by the discovery info provider. + assert engine.default_language == "en-US" + assert engine.default_options == {"gender": "female", "audio_output": "mp3"} + + await set_cloud_prefs({"tts_default_voice": ("nl-NL", "male")}) + await hass.async_block_till_done() + + assert engine.default_language == "nl-NL" + assert engine.default_options == {"gender": "male", "audio_output": "mp3"} -async def test_provider_properties(cloud_with_prefs) -> None: +async def test_provider_properties( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages - assert tts.Voice( - "ColetteNeural", "ColetteNeural" - ) in provider.async_get_supported_voices("nl-NL") + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + engine = get_engine_instance(hass, DOMAIN) + + assert engine is not None + assert engine.supported_options == ["gender", "voice", "audio_output"] + assert "nl-NL" in engine.supported_languages + supported_voices = engine.async_get_supported_voices("nl-NL") + assert supported_voices is not None + assert tts.Voice("ColetteNeural", "ColetteNeural") in supported_voices + supported_voices = engine.async_get_supported_voices("missing_language") + assert supported_voices is None -async def test_get_tts_audio(cloud_with_prefs) -> None: +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ], +) +@pytest.mark.parametrize( + ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + [ + (b"", None), + (None, VoiceError("Boom!")), + ], +) +async def test_get_tts_audio( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + cloud: MagicMock, + data: dict[str, Any], + expected_url_suffix: str, + mock_process_tts_return_value: bytes | None, + mock_process_tts_side_effect: Exception | None, +) -> None: """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info + mock_process_tts = AsyncMock( + return_value=mock_process_tts_return_value, + side_effect=mock_process_tts_side_effect, ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + client = await hass_client() + + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" From 99734a76aa4741aca62d250054ae93037c4cc2a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Dec 2023 21:48:27 -1000 Subject: [PATCH 754/927] Use faster contains check in water_heater (#106438) --- homeassistant/components/water_heater/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 8e336533fc3..ddef4e7366c 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -241,7 +241,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -277,10 +277,12 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + supported_features = self.supported_features + + if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation - if self.supported_features & WaterHeaterEntityFeature.AWAY_MODE: + if WaterHeaterEntityFeature.AWAY_MODE in supported_features: is_away = self.is_away_mode_on data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF From 65e8bbacc9730665c7d30398933e510ac917a39d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 27 Dec 2023 08:50:46 +0100 Subject: [PATCH 755/927] Raise HomeAssistantError if event is triggered with invalid event_type (#106357) --- homeassistant/components/event/__init__.py | 13 ++++++++++++- homeassistant/components/event/strings.json | 5 +++++ homeassistant/components/mqtt/event.py | 3 ++- tests/components/event/test_init.py | 4 +++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index b05c3a6f3a5..fb94411fc36 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Self, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -154,7 +155,17 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) ) -> None: """Process a new event.""" if event_type not in self.event_types: - raise ValueError(f"Invalid event type {event_type} for {self.entity_id}") + event_types: str = ", ".join(self.event_types) + raise HomeAssistantError( + f"Invalid event type {event_type} for {self.entity_id}", + translation_key="invalid_event_type", + translation_domain=DOMAIN, + translation_placeholders={ + "event_type": event_type, + "event_types": event_types, + "entity_id": self.entity_id, + }, + ) self.__last_event_triggered = dt_util.utcnow() self.__last_event_type = event_type self.__last_event_attributes = event_attributes diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index 02f4da8ca08..2dd4089ded9 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -21,5 +21,10 @@ "motion": { "name": "Motion" } + }, + "exceptions": { + "invalid_event_type": { + "message": "Invalid event type {event_type} for {entity_id}, valid types are: {event_types}." + } } } diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c9302bf65b1..b5e8e9000f7 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -16,6 +16,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -174,7 +175,7 @@ class MqttEvent(MqttEntity, EventEntity): return try: self._trigger_event(event_type, event_attributes) - except ValueError: + except HomeAssistantError: _LOGGER.warning( "Invalid event type %s for %s received on topic %s, payload %s", event_type, diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index b8ba5fb6a18..7d3e5e201e3 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -16,6 +16,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component @@ -90,7 +91,8 @@ async def test_event() -> None: # Test triggering an unknown event with pytest.raises( - ValueError, match="^Invalid event type unknown_event for event.doorbell$" + HomeAssistantError, + match="^Invalid event type unknown_event for event.doorbell$", ): event._trigger_event("unknown_event") From f92e732f271d1e5365c5a8db281be3699a1353cd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Dec 2023 09:01:53 +0100 Subject: [PATCH 756/927] Use translation placeholders in Swiss public transport (#106416) --- .../components/swiss_public_transport/config_flow.py | 7 +++++-- .../components/swiss_public_transport/const.py | 6 ++++++ .../components/swiss_public_transport/sensor.py | 5 +++-- .../components/swiss_public_transport/strings.json | 10 +++++----- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 534099f09e6..63eca1efe96 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -15,7 +15,7 @@ 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_DESTINATION, CONF_START, DOMAIN +from .const import CONF_DESTINATION, CONF_START, DOMAIN, PLACEHOLDERS DATA_SCHEMA = vol.Schema( { @@ -65,7 +65,10 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders=PLACEHOLDERS, ) async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index 3ce351498ee..d14a77feb2a 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -7,3 +7,9 @@ CONF_DESTINATION = "to" CONF_START = "from" DEFAULT_NAME = "Next Destination" + + +PLACEHOLDERS = { + "stationboard_url": "http://transport.opendata.ch/examples/stationboard.html", + "opendata_url": "http://transport.opendata.ch", +} diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 0a69cf12085..bc03b8d61e1 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN +from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN, PLACEHOLDERS _LOGGER = logging.getLogger(__name__) @@ -104,11 +104,12 @@ async def async_setup_platform( issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=PLACEHOLDERS, ) class SwissPublicTransportSensor(SensorEntity): - """Implementation of an Swiss public transport sensor.""" + """Implementation of a Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 097252634ea..01736beba78 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,13 +2,13 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", "unknown": "An unknown error was raised by python-opendata-transport" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the [stationboard]({stationboard_url}) for valid stations.", "unknown": "An unknown error was raised by python-opendata-transport" }, "step": { @@ -17,7 +17,7 @@ "from": "Start station", "to": "End station" }, - "description": "Provide start and end station for your connection\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)", + "description": "Provide start and end station for your connection\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" } } @@ -25,11 +25,11 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The swiss public transport YAML configuration import cannot connect to server", - "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server](http://transport.opendata.ch). In case the server is down, try again later." + "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server]({opendata_url}). In case the server is down, try again later." }, "deprecated_yaml_import_issue_bad_config": { "title": "The swiss public transport YAML configuration import request failed due to bad config", - "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration..\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)" + "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration.\n\nCheck the [stationboard]({stationboard_url}) for valid stations." }, "deprecated_yaml_import_issue_unknown": { "title": "The swiss public transport YAML configuration import failed with unknown error raised by python-opendata-transport", From fbcb31b1030e51cab2aac95484c4008fab40fbe2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 27 Dec 2023 09:04:25 +0100 Subject: [PATCH 757/927] Deprecate deprecated unit of measurement constants (#106455) --- homeassistant/const.py | 452 ++++++++++++++---- homeassistant/core.py | 5 +- .../geo_json_events/test_geo_location.py | 8 +- .../specific_devices/test_connectsense.py | 30 +- .../specific_devices/test_koogeek_sw2.py | 4 +- .../specific_devices/test_vocolinc_vp3.py | 4 +- tests/helpers/test_template.py | 4 +- tests/test_const.py | 103 +++- .../custom_components/test/sensor.py | 12 +- 9 files changed, 479 insertions(+), 143 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a5965ca713..f6d479aeb42 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -621,7 +621,10 @@ class UnitOfApparentPower(StrEnum): VOLT_AMPERE = "VA" -POWER_VOLT_AMPERE: Final = "VA" +_DEPRECATED_POWER_VOLT_AMPERE: Final = ( + UnitOfApparentPower.VOLT_AMPERE, + "2025.1", +) """Deprecated: please use UnitOfApparentPower.VOLT_AMPERE.""" @@ -634,11 +637,20 @@ class UnitOfPower(StrEnum): BTU_PER_HOUR = "BTU/h" -POWER_WATT: Final = "W" +_DEPRECATED_POWER_WATT: Final = ( + UnitOfPower.WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.WATT.""" -POWER_KILO_WATT: Final = "kW" +_DEPRECATED_POWER_KILO_WATT: Final = ( + UnitOfPower.KILO_WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.KILO_WATT.""" -POWER_BTU_PER_HOUR: Final = "BTU/h" +_DEPRECATED_POWER_BTU_PER_HOUR: Final = ( + UnitOfPower.BTU_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" # Reactive power units @@ -656,11 +668,20 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" -ENERGY_KILO_WATT_HOUR: Final = "kWh" +_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = ( + UnitOfEnergy.KILO_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" -ENERGY_MEGA_WATT_HOUR: Final = "MWh" +_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = ( + UnitOfEnergy.MEGA_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" -ENERGY_WATT_HOUR: Final = "Wh" +_DEPRECATED_ENERGY_WATT_HOUR: Final = ( + UnitOfEnergy.WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.WATT_HOUR.""" @@ -672,9 +693,15 @@ class UnitOfElectricCurrent(StrEnum): AMPERE = "A" -ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" +_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = ( + UnitOfElectricCurrent.MILLIAMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE.""" -ELECTRIC_CURRENT_AMPERE: Final = "A" +_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = ( + UnitOfElectricCurrent.AMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.AMPERE.""" @@ -686,9 +713,15 @@ class UnitOfElectricPotential(StrEnum): VOLT = "V" -ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" +_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = ( + UnitOfElectricPotential.MILLIVOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.MILLIVOLT.""" -ELECTRIC_POTENTIAL_VOLT: Final = "V" +_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = ( + UnitOfElectricPotential.VOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.VOLT.""" # Degree units @@ -709,11 +742,20 @@ class UnitOfTemperature(StrEnum): KELVIN = "K" -TEMP_CELSIUS: Final = "°C" +_DEPRECATED_TEMP_CELSIUS: Final = ( + UnitOfTemperature.CELSIUS, + "2025.1", +) """Deprecated: please use UnitOfTemperature.CELSIUS""" -TEMP_FAHRENHEIT: Final = "°F" +_DEPRECATED_TEMP_FAHRENHEIT: Final = ( + UnitOfTemperature.FAHRENHEIT, + "2025.1", +) """Deprecated: please use UnitOfTemperature.FAHRENHEIT""" -TEMP_KELVIN: Final = "K" +_DEPRECATED_TEMP_KELVIN: Final = ( + UnitOfTemperature.KELVIN, + "2025.1", +) """Deprecated: please use UnitOfTemperature.KELVIN""" @@ -732,23 +774,50 @@ class UnitOfTime(StrEnum): YEARS = "y" -TIME_MICROSECONDS: Final = "μs" +_DEPRECATED_TIME_MICROSECONDS: Final = ( + UnitOfTime.MICROSECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MICROSECONDS.""" -TIME_MILLISECONDS: Final = "ms" +_DEPRECATED_TIME_MILLISECONDS: Final = ( + UnitOfTime.MILLISECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MILLISECONDS.""" -TIME_SECONDS: Final = "s" +_DEPRECATED_TIME_SECONDS: Final = ( + UnitOfTime.SECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.SECONDS.""" -TIME_MINUTES: Final = "min" +_DEPRECATED_TIME_MINUTES: Final = ( + UnitOfTime.MINUTES, + "2025.1", +) """Deprecated: please use UnitOfTime.MINUTES.""" -TIME_HOURS: Final = "h" +_DEPRECATED_TIME_HOURS: Final = ( + UnitOfTime.HOURS, + "2025.1", +) """Deprecated: please use UnitOfTime.HOURS.""" -TIME_DAYS: Final = "d" +_DEPRECATED_TIME_DAYS: Final = ( + UnitOfTime.DAYS, + "2025.1", +) """Deprecated: please use UnitOfTime.DAYS.""" -TIME_WEEKS: Final = "w" +_DEPRECATED_TIME_WEEKS: Final = ( + UnitOfTime.WEEKS, + "2025.1", +) """Deprecated: please use UnitOfTime.WEEKS.""" -TIME_MONTHS: Final = "m" +_DEPRECATED_TIME_MONTHS: Final = ( + UnitOfTime.MONTHS, + "2025.1", +) """Deprecated: please use UnitOfTime.MONTHS.""" -TIME_YEARS: Final = "y" +_DEPRECATED_TIME_YEARS: Final = ( + UnitOfTime.YEARS, + "2025.1", +) """Deprecated: please use UnitOfTime.YEARS.""" @@ -766,21 +835,45 @@ class UnitOfLength(StrEnum): MILES = "mi" -LENGTH_MILLIMETERS: Final = "mm" +_DEPRECATED_LENGTH_MILLIMETERS: Final = ( + UnitOfLength.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.MILLIMETERS.""" -LENGTH_CENTIMETERS: Final = "cm" +_DEPRECATED_LENGTH_CENTIMETERS: Final = ( + UnitOfLength.CENTIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.CENTIMETERS.""" -LENGTH_METERS: Final = "m" +_DEPRECATED_LENGTH_METERS: Final = ( + UnitOfLength.METERS, + "2025.1", +) """Deprecated: please use UnitOfLength.METERS.""" -LENGTH_KILOMETERS: Final = "km" +_DEPRECATED_LENGTH_KILOMETERS: Final = ( + UnitOfLength.KILOMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.KILOMETERS.""" -LENGTH_INCHES: Final = "in" +_DEPRECATED_LENGTH_INCHES: Final = ( + UnitOfLength.INCHES, + "2025.1", +) """Deprecated: please use UnitOfLength.INCHES.""" -LENGTH_FEET: Final = "ft" +_DEPRECATED_LENGTH_FEET: Final = ( + UnitOfLength.FEET, + "2025.1", +) """Deprecated: please use UnitOfLength.FEET.""" -LENGTH_YARD: Final = "yd" +_DEPRECATED_LENGTH_YARD: Final = ( + UnitOfLength.YARDS, + "2025.1", +) """Deprecated: please use UnitOfLength.YARDS.""" -LENGTH_MILES: Final = "mi" +_DEPRECATED_LENGTH_MILES: Final = ( + UnitOfLength.MILES, + "2025.1", +) """Deprecated: please use UnitOfLength.MILES.""" @@ -794,13 +887,25 @@ class UnitOfFrequency(StrEnum): GIGAHERTZ = "GHz" -FREQUENCY_HERTZ: Final = "Hz" +_DEPRECATED_FREQUENCY_HERTZ: Final = ( + UnitOfFrequency.HERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.HERTZ""" -FREQUENCY_KILOHERTZ: Final = "kHz" +_DEPRECATED_FREQUENCY_KILOHERTZ: Final = ( + UnitOfFrequency.KILOHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.KILOHERTZ""" -FREQUENCY_MEGAHERTZ: Final = "MHz" +_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = ( + UnitOfFrequency.MEGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.MEGAHERTZ""" -FREQUENCY_GIGAHERTZ: Final = "GHz" +_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = ( + UnitOfFrequency.GIGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.GIGAHERTZ""" @@ -819,23 +924,50 @@ class UnitOfPressure(StrEnum): PSI = "psi" -PRESSURE_PA: Final = "Pa" +_DEPRECATED_PRESSURE_PA: Final = ( + UnitOfPressure.PA, + "2025.1", +) """Deprecated: please use UnitOfPressure.PA""" -PRESSURE_HPA: Final = "hPa" +_DEPRECATED_PRESSURE_HPA: Final = ( + UnitOfPressure.HPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.HPA""" -PRESSURE_KPA: Final = "kPa" +_DEPRECATED_PRESSURE_KPA: Final = ( + UnitOfPressure.KPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.KPA""" -PRESSURE_BAR: Final = "bar" +_DEPRECATED_PRESSURE_BAR: Final = ( + UnitOfPressure.BAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.BAR""" -PRESSURE_CBAR: Final = "cbar" +_DEPRECATED_PRESSURE_CBAR: Final = ( + UnitOfPressure.CBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.CBAR""" -PRESSURE_MBAR: Final = "mbar" +_DEPRECATED_PRESSURE_MBAR: Final = ( + UnitOfPressure.MBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.MBAR""" -PRESSURE_MMHG: Final = "mmHg" +_DEPRECATED_PRESSURE_MMHG: Final = ( + UnitOfPressure.MMHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.MMHG""" -PRESSURE_INHG: Final = "inHg" +_DEPRECATED_PRESSURE_INHG: Final = ( + UnitOfPressure.INHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.INHG""" -PRESSURE_PSI: Final = "psi" +_DEPRECATED_PRESSURE_PSI: Final = ( + UnitOfPressure.PSI, + "2025.1", +) """Deprecated: please use UnitOfPressure.PSI""" @@ -847,9 +979,15 @@ class UnitOfSoundPressure(StrEnum): WEIGHTED_DECIBEL_A = "dBA" -SOUND_PRESSURE_DB: Final = "dB" +_DEPRECATED_SOUND_PRESSURE_DB: Final = ( + UnitOfSoundPressure.DECIBEL, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.DECIBEL""" -SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" +_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = ( + UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A""" @@ -872,18 +1010,36 @@ class UnitOfVolume(StrEnum): British/Imperial fluid ounces are not yet supported""" -VOLUME_LITERS: Final = "L" +_DEPRECATED_VOLUME_LITERS: Final = ( + UnitOfVolume.LITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.LITERS""" -VOLUME_MILLILITERS: Final = "mL" +_DEPRECATED_VOLUME_MILLILITERS: Final = ( + UnitOfVolume.MILLILITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.MILLILITERS""" -VOLUME_CUBIC_METERS: Final = "m³" +_DEPRECATED_VOLUME_CUBIC_METERS: Final = ( + UnitOfVolume.CUBIC_METERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_METERS""" -VOLUME_CUBIC_FEET: Final = "ft³" +_DEPRECATED_VOLUME_CUBIC_FEET: Final = ( + UnitOfVolume.CUBIC_FEET, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_FEET""" -VOLUME_GALLONS: Final = "gal" +_DEPRECATED_VOLUME_GALLONS: Final = ( + UnitOfVolume.GALLONS, + "2025.1", +) """Deprecated: please use UnitOfVolume.GALLONS""" -VOLUME_FLUID_OUNCE: Final = "fl. oz." +_DEPRECATED_VOLUME_FLUID_OUNCE: Final = ( + UnitOfVolume.FLUID_OUNCES, + "2025.1", +) """Deprecated: please use UnitOfVolume.FLUID_OUNCES""" @@ -895,9 +1051,15 @@ class UnitOfVolumeFlowRate(StrEnum): CUBIC_FEET_PER_MINUTE = "ft³/m" -VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = "m³/h" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR""" -VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = "ft³/m" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = ( + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" # Area units @@ -917,17 +1079,35 @@ class UnitOfMass(StrEnum): STONES = "st" -MASS_GRAMS: Final = "g" +_DEPRECATED_MASS_GRAMS: Final = ( + UnitOfMass.GRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.GRAMS""" -MASS_KILOGRAMS: Final = "kg" +_DEPRECATED_MASS_KILOGRAMS: Final = ( + UnitOfMass.KILOGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.KILOGRAMS""" -MASS_MILLIGRAMS: Final = "mg" +_DEPRECATED_MASS_MILLIGRAMS: Final = ( + UnitOfMass.MILLIGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MILLIGRAMS""" -MASS_MICROGRAMS: Final = "µg" +_DEPRECATED_MASS_MICROGRAMS: Final = ( + UnitOfMass.MICROGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MICROGRAMS""" -MASS_OUNCES: Final = "oz" +_DEPRECATED_MASS_OUNCES: Final = ( + UnitOfMass.OUNCES, + "2025.1", +) """Deprecated: please use UnitOfMass.OUNCES""" -MASS_POUNDS: Final = "lb" +_DEPRECATED_MASS_POUNDS: Final = ( + UnitOfMass.POUNDS, + "2025.1", +) """Deprecated: please use UnitOfMass.POUNDS""" # Conductivity units @@ -955,9 +1135,15 @@ class UnitOfIrradiance(StrEnum): # Irradiation units -IRRADIATION_WATTS_PER_SQUARE_METER: Final = "W/m²" +_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = ( + UnitOfIrradiance.WATTS_PER_SQUARE_METER, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER""" -IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" +_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = ( + UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT""" @@ -999,13 +1185,22 @@ class UnitOfPrecipitationDepth(StrEnum): # Precipitation units -PRECIPITATION_INCHES: Final = "in" +_DEPRECATED_PRECIPITATION_INCHES: Final = (UnitOfPrecipitationDepth.INCHES, "2025.1") """Deprecated: please use UnitOfPrecipitationDepth.INCHES""" -PRECIPITATION_MILLIMETERS: Final = "mm" +_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = ( + UnitOfPrecipitationDepth.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS""" -PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = ( + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" -PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = ( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" # Concentration units @@ -1028,24 +1223,36 @@ class UnitOfSpeed(StrEnum): MILES_PER_HOUR = "mph" -SPEED_FEET_PER_SECOND: Final = "ft/s" +_DEPRECATED_SPEED_FEET_PER_SECOND: Final = (UnitOfSpeed.FEET_PER_SECOND, "2025.1") """Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" -SPEED_METERS_PER_SECOND: Final = "m/s" +_DEPRECATED_SPEED_METERS_PER_SECOND: Final = (UnitOfSpeed.METERS_PER_SECOND, "2025.1") """Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" -SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" -SPEED_KNOTS: Final = "kn" +_DEPRECATED_SPEED_KNOTS: Final = (UnitOfSpeed.KNOTS, "2025.1") """Deprecated: please use UnitOfSpeed.KNOTS""" -SPEED_MILES_PER_HOUR: Final = "mph" +_DEPRECATED_SPEED_MILES_PER_HOUR: Final = (UnitOfSpeed.MILES_PER_HOUR, "2025.1") """Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" -SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = ( + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" -SPEED_INCHES_PER_DAY: Final = "in/d" +_DEPRECATED_SPEED_INCHES_PER_DAY: Final = ( + UnitOfVolumetricFlux.INCHES_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" -SPEED_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = ( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" @@ -1081,47 +1288,47 @@ class UnitOfInformation(StrEnum): YOBIBYTES = "YiB" -DATA_BITS: Final = "bit" +_DEPRECATED_DATA_BITS: Final = (UnitOfInformation.BITS, "2025.1") """Deprecated: please use UnitOfInformation.BITS""" -DATA_KILOBITS: Final = "kbit" +_DEPRECATED_DATA_KILOBITS: Final = (UnitOfInformation.KILOBITS, "2025.1") """Deprecated: please use UnitOfInformation.KILOBITS""" -DATA_MEGABITS: Final = "Mbit" +_DEPRECATED_DATA_MEGABITS: Final = (UnitOfInformation.MEGABITS, "2025.1") """Deprecated: please use UnitOfInformation.MEGABITS""" -DATA_GIGABITS: Final = "Gbit" +_DEPRECATED_DATA_GIGABITS: Final = (UnitOfInformation.GIGABITS, "2025.1") """Deprecated: please use UnitOfInformation.GIGABITS""" -DATA_BYTES: Final = "B" +_DEPRECATED_DATA_BYTES: Final = (UnitOfInformation.BYTES, "2025.1") """Deprecated: please use UnitOfInformation.BYTES""" -DATA_KILOBYTES: Final = "kB" +_DEPRECATED_DATA_KILOBYTES: Final = (UnitOfInformation.KILOBYTES, "2025.1") """Deprecated: please use UnitOfInformation.KILOBYTES""" -DATA_MEGABYTES: Final = "MB" +_DEPRECATED_DATA_MEGABYTES: Final = (UnitOfInformation.MEGABYTES, "2025.1") """Deprecated: please use UnitOfInformation.MEGABYTES""" -DATA_GIGABYTES: Final = "GB" +_DEPRECATED_DATA_GIGABYTES: Final = (UnitOfInformation.GIGABYTES, "2025.1") """Deprecated: please use UnitOfInformation.GIGABYTES""" -DATA_TERABYTES: Final = "TB" +_DEPRECATED_DATA_TERABYTES: Final = (UnitOfInformation.TERABYTES, "2025.1") """Deprecated: please use UnitOfInformation.TERABYTES""" -DATA_PETABYTES: Final = "PB" +_DEPRECATED_DATA_PETABYTES: Final = (UnitOfInformation.PETABYTES, "2025.1") """Deprecated: please use UnitOfInformation.PETABYTES""" -DATA_EXABYTES: Final = "EB" +_DEPRECATED_DATA_EXABYTES: Final = (UnitOfInformation.EXABYTES, "2025.1") """Deprecated: please use UnitOfInformation.EXABYTES""" -DATA_ZETTABYTES: Final = "ZB" +_DEPRECATED_DATA_ZETTABYTES: Final = (UnitOfInformation.ZETTABYTES, "2025.1") """Deprecated: please use UnitOfInformation.ZETTABYTES""" -DATA_YOTTABYTES: Final = "YB" +_DEPRECATED_DATA_YOTTABYTES: Final = (UnitOfInformation.YOTTABYTES, "2025.1") """Deprecated: please use UnitOfInformation.YOTTABYTES""" -DATA_KIBIBYTES: Final = "KiB" +_DEPRECATED_DATA_KIBIBYTES: Final = (UnitOfInformation.KIBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.KIBIBYTES""" -DATA_MEBIBYTES: Final = "MiB" +_DEPRECATED_DATA_MEBIBYTES: Final = (UnitOfInformation.MEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.MEBIBYTES""" -DATA_GIBIBYTES: Final = "GiB" +_DEPRECATED_DATA_GIBIBYTES: Final = (UnitOfInformation.GIBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.GIBIBYTES""" -DATA_TEBIBYTES: Final = "TiB" +_DEPRECATED_DATA_TEBIBYTES: Final = (UnitOfInformation.TEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.TEBIBYTES""" -DATA_PEBIBYTES: Final = "PiB" +_DEPRECATED_DATA_PEBIBYTES: Final = (UnitOfInformation.PEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.PEBIBYTES""" -DATA_EXBIBYTES: Final = "EiB" +_DEPRECATED_DATA_EXBIBYTES: Final = (UnitOfInformation.EXBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.EXBIBYTES""" -DATA_ZEBIBYTES: Final = "ZiB" +_DEPRECATED_DATA_ZEBIBYTES: Final = (UnitOfInformation.ZEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.ZEBIBYTES""" -DATA_YOBIBYTES: Final = "YiB" +_DEPRECATED_DATA_YOBIBYTES: Final = (UnitOfInformation.YOBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.YOBIBYTES""" @@ -1142,27 +1349,60 @@ class UnitOfDataRate(StrEnum): GIBIBYTES_PER_SECOND = "GiB/s" -DATA_RATE_BITS_PER_SECOND: Final = "bit/s" +_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = ( + UnitOfDataRate.BITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BITS_PER_SECOND""" -DATA_RATE_KILOBITS_PER_SECOND: Final = "kbit/s" +_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = ( + UnitOfDataRate.KILOBITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND""" -DATA_RATE_MEGABITS_PER_SECOND: Final = "Mbit/s" +_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = ( + UnitOfDataRate.MEGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND""" -DATA_RATE_GIGABITS_PER_SECOND: Final = "Gbit/s" +_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = ( + UnitOfDataRate.GIGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND""" -DATA_RATE_BYTES_PER_SECOND: Final = "B/s" +_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = ( + UnitOfDataRate.BYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND""" -DATA_RATE_KILOBYTES_PER_SECOND: Final = "kB/s" +_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = ( + UnitOfDataRate.KILOBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND""" -DATA_RATE_MEGABYTES_PER_SECOND: Final = "MB/s" +_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = ( + UnitOfDataRate.MEGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND""" -DATA_RATE_GIGABYTES_PER_SECOND: Final = "GB/s" +_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = ( + UnitOfDataRate.GIGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND""" -DATA_RATE_KIBIBYTES_PER_SECOND: Final = "KiB/s" +_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.KIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND""" -DATA_RATE_MEBIBYTES_PER_SECOND: Final = "MiB/s" +_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.MEBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND""" -DATA_RATE_GIBIBYTES_PER_SECOND: Final = "GiB/s" +_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.GIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND""" diff --git a/homeassistant/core.py b/homeassistant/core.py index da49f30d58a..fc0bc5ebe5a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -67,10 +67,10 @@ from .const import ( EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, - LENGTH_METERS, MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, + UnitOfLength, __version__, ) from .exceptions import ( @@ -2275,7 +2275,8 @@ class Config: Async friendly. """ return self.units.length( - location.distance(self.latitude, self.longitude, lat, lon), LENGTH_METERS + location.distance(self.latitude, self.longitude, lat, lon), + UnitOfLength.METERS, ) def path(self, *path: str) -> str: diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index a44357a5763..3875a525e73 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, - LENGTH_KILOMETERS, + UnitOfLength, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -77,7 +77,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 15.5), 7) == 0 @@ -90,7 +90,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 20.5), 7) == 0 @@ -103,7 +103,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.2, ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 25.5), 7) == 0 diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 44157c32203..9e08c6fed0a 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -1,10 +1,6 @@ """Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -39,7 +35,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -47,7 +43,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -55,7 +51,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -69,7 +65,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -77,7 +73,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -85,7 +81,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( @@ -118,7 +114,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -126,7 +122,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -134,7 +130,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -148,7 +144,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -156,7 +152,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -164,7 +160,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 44293ac439c..7114d138039 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -5,7 +5,7 @@ This Koogeek device has a custom power sensor that extra handling. It should have 2 entities - the actual switch and a sensor for power usage. """ from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -51,7 +51,7 @@ async def test_koogeek_sw2_setup(hass: HomeAssistant) -> None: entity_id="sensor.koogeek_sw2_187a91_power", friendly_name="Koogeek-SW2-187A91 Power", unique_id="00:00:00:00:00:00_1_14_18", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 6d3c242c382..b42a7652c1c 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -1,6 +1,6 @@ """Make sure that existing VOCOlinc VP3 support isn't broken.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,7 +58,7 @@ async def test_vocolinc_vp3_setup( entity_id="sensor.original_vocolinc_vp3_power", friendly_name="VOCOlinc-VP3-123456 Power", unique_id="00:00:00:00:00:00_1_48_97", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 58d52dfc395..cdb272e2d97 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -22,13 +22,13 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_UNAVAILABLE, - VOLUME_LITERS, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -60,7 +60,7 @@ def _set_up_units(hass: HomeAssistant) -> None: mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, temperature=UnitOfTemperature.CELSIUS, - volume=VOLUME_LITERS, + volume=UnitOfVolume.LITERS, wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, ) diff --git a/tests/test_const.py b/tests/test_const.py index b06b05b27bd..fedf35ae6d1 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -8,7 +8,10 @@ import pytest from homeassistant import const from homeassistant.components import sensor -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import ( + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, +) def _create_tuples( @@ -55,7 +58,78 @@ def _create_tuples( sensor.SensorDeviceClass.VOLTAGE, ], "DEVICE_CLASS_", - ), + ) + + _create_tuples(const.UnitOfApparentPower, "POWER_") + + _create_tuples(const.UnitOfPower, "POWER_") + + _create_tuples( + [ + const.UnitOfEnergy.KILO_WATT_HOUR, + const.UnitOfEnergy.MEGA_WATT_HOUR, + const.UnitOfEnergy.WATT_HOUR, + ], + "ENERGY_", + ) + + _create_tuples(const.UnitOfElectricCurrent, "ELECTRIC_CURRENT_") + + _create_tuples(const.UnitOfElectricPotential, "ELECTRIC_POTENTIAL_") + + _create_tuples(const.UnitOfTemperature, "TEMP_") + + _create_tuples(const.UnitOfTime, "TIME_") + + _create_tuples( + [ + const.UnitOfLength.MILLIMETERS, + const.UnitOfLength.CENTIMETERS, + const.UnitOfLength.METERS, + const.UnitOfLength.KILOMETERS, + const.UnitOfLength.INCHES, + const.UnitOfLength.FEET, + const.UnitOfLength.MILES, + ], + "LENGTH_", + ) + + _create_tuples(const.UnitOfFrequency, "FREQUENCY_") + + _create_tuples(const.UnitOfPressure, "PRESSURE_") + + _create_tuples( + [ + const.UnitOfVolume.CUBIC_FEET, + const.UnitOfVolume.CUBIC_METERS, + const.UnitOfVolume.LITERS, + const.UnitOfVolume.MILLILITERS, + const.UnitOfVolume.GALLONS, + ], + "VOLUME_", + ) + + _create_tuples(const.UnitOfVolumeFlowRate, "VOLUME_FLOW_RATE_") + + _create_tuples( + [ + const.UnitOfMass.GRAMS, + const.UnitOfMass.KILOGRAMS, + const.UnitOfMass.MILLIGRAMS, + const.UnitOfMass.MICROGRAMS, + const.UnitOfMass.OUNCES, + const.UnitOfMass.POUNDS, + ], + "MASS_", + ) + + _create_tuples(const.UnitOfIrradiance, "IRRADIATION_") + + _create_tuples( + [ + const.UnitOfPrecipitationDepth.INCHES, + const.UnitOfPrecipitationDepth.MILLIMETERS, + const.UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "PRECIPITATION_", + ) + + _create_tuples(const.UnitOfSpeed, "SPEED_") + + _create_tuples( + [ + const.UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "SPEED_", + ) + + _create_tuples(const.UnitOfInformation, "DATA_") + + _create_tuples(const.UnitOfDataRate, "DATA_RATE_"), ) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, @@ -66,3 +140,28 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, const, enum, constant_prefix, "2025.1" ) + + +@pytest.mark.parametrize( + ("replacement", "constant_name"), + [ + (const.UnitOfLength.YARDS, "LENGTH_YARD"), + (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB"), + (const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "SOUND_PRESSURE_WEIGHTED_DBA"), + (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE"), + ], +) +def test_deprecated_constant_name_changes( + caplog: pytest.LogCaptureFixture, + replacement: Enum, + constant_name: str, +) -> None: + """Test deprecated constants, where the name is not the same as the enum value.""" + import_and_test_deprecated_constant( + caplog, + const, + constant_name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + "2025.1", + ) diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 4eae52fd4a5..d436a94e329 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -11,14 +11,14 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - FREQUENCY_GIGAHERTZ, LIGHT_LUX, PERCENTAGE, - POWER_VOLT_AMPERE, POWER_VOLT_AMPERE_REACTIVE, SIGNAL_STRENGTH_DECIBELS, - VOLUME_CUBIC_METERS, + UnitOfApparentPower, + UnitOfFrequency, UnitOfPressure, + UnitOfVolume, ) from tests.common import MockEntity @@ -26,7 +26,7 @@ from tests.common import MockEntity DEVICE_CLASSES.append("none") UNITS_OF_MEASUREMENT = { - SensorDeviceClass.APPARENT_POWER: POWER_VOLT_AMPERE, # apparent power (VA) + SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, # apparent power (VA) SensorDeviceClass.BATTERY: PERCENTAGE, # % of battery that is left SensorDeviceClass.CO: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO concentration SensorDeviceClass.CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration @@ -47,12 +47,12 @@ UNITS_OF_MEASUREMENT = { SensorDeviceClass.POWER: "kW", # power (W/kW) SensorDeviceClass.CURRENT: "A", # current (A) SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) - SensorDeviceClass.FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) + SensorDeviceClass.FREQUENCY: UnitOfFrequency.GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) SensorDeviceClass.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, # reactive power (var) SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs SensorDeviceClass.VOLTAGE: "V", # voltage (V) - SensorDeviceClass.GAS: VOLUME_CUBIC_METERS, # gas (m³) + SensorDeviceClass.GAS: UnitOfVolume.CUBIC_METERS, # gas (m³) } ENTITIES = {} From c7eab49c70373a45e1036e7e4c25991b4b6ff1bd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 27 Dec 2023 09:45:55 +0100 Subject: [PATCH 758/927] Raise ServiceValidationError on invalid select option (#106350) * Raise ServiceValidationError on invalid select option * Fix tests * Correct place holders * More test fixes --- homeassistant/components/select/__init__.py | 37 ++++++++++++++----- homeassistant/components/select/strings.json | 5 +++ tests/components/airzone/test_select.py | 3 +- .../bmw_connected_drive/test_select.py | 6 +-- tests/components/demo/test_select.py | 3 +- tests/components/flux_led/test_select.py | 11 +++--- tests/components/knx/test_select.py | 3 +- tests/components/litterrobot/test_select.py | 3 +- .../rituals_perfume_genie/test_select.py | 3 +- tests/components/select/test_init.py | 10 +++-- tests/components/xiaomi_miio/test_select.py | 3 +- 11 files changed, 60 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 459083cedd4..8ec08f4606f 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -8,7 +8,8 @@ from typing import TYPE_CHECKING, Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -90,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SELECT_OPTION, {vol.Required(ATTR_OPTION): cv.string}, - async_select_option, + SelectEntity.async_handle_select_option.__name__, ) component.async_register_entity_service( @@ -102,14 +103,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_select_option(entity: SelectEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - option = service_call.data[ATTR_OPTION] - if option not in entity.options: - raise ValueError(f"Option {option} not valid for {entity.entity_id}") - await entity.async_select_option(option) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[SelectEntity] = hass.data[DOMAIN] @@ -177,6 +170,30 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the selected entity option to represent the entity state.""" return self._attr_current_option + @final + @callback + def _valid_option_or_raise(self, option: str) -> None: + """Raise ServiceValidationError on invalid option.""" + options = self.options + if not options or option not in options: + friendly_options: str = ", ".join(options or []) + raise ServiceValidationError( + f"Option {option} is not valid for {self.entity_id}", + translation_domain=DOMAIN, + translation_key="not_valid_option", + translation_placeholders={ + "entity_id": self.entity_id, + "option": option, + "options": friendly_options, + }, + ) + + @final + async def async_handle_select_option(self, option: str) -> None: + """Service call wrapper to set a new value.""" + self._valid_option_or_raise(option) + await self.async_select_option(option) + def select_option(self, option: str) -> None: """Change the selected option.""" raise NotImplementedError() diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index d058ff6e6f2..9c9d1136b99 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -64,5 +64,10 @@ } } } + }, + "exceptions": { + "not_valid_option": { + "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." + } } } diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index c7c32022123..01617eab175 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -15,6 +15,7 @@ import pytest from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .util import async_init_integration @@ -85,7 +86,7 @@ async def test_airzone_select_sleep(hass: HomeAssistant) -> None: ] } - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 2dbe66139b2..1860ed19720 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -8,7 +8,7 @@ import respx from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import check_remote_service_call, setup_mocked_integration @@ -92,7 +92,7 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", @@ -108,7 +108,7 @@ async def test_service_call_invalid_input( [ (MyBMWRemoteServiceError, HomeAssistantError), (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + (ServiceValidationError, ServiceValidationError), ], ) async def test_service_call_fail( diff --git a/tests/components/demo/test_select.py b/tests/components/demo/test_select.py index a4fff2a231e..013a9900a83 100644 --- a/tests/components/demo/test_select.py +++ b/tests/components/demo/test_select.py @@ -11,6 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component ENTITY_SPEED = "select.speed" @@ -51,7 +52,7 @@ async def test_select_option_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "ridiculous_speed" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py index c8fd64c6811..1cdbb9369ab 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -14,6 +14,7 @@ from homeassistant.components.flux_led.const import CONF_WHITE_CHANNEL_TYPE, DOM from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -133,7 +134,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(ic_type_entity_id) assert state.state == "WS2812B" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -149,7 +150,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: bulb.async_set_device_config.assert_called_once_with(wiring="GRBW") bulb.async_set_device_config.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -191,7 +192,7 @@ async def test_select_mutable_0x25_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == "RGBWW" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -226,7 +227,7 @@ async def test_select_24ghz_remote_config(hass: HomeAssistant) -> None: state = hass.states.get(remote_config_entity_id) assert state.state == "Open" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -275,7 +276,7 @@ async def test_select_white_channel_type(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == WhiteChannelType.WARM.name.title() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index f113a83f7a0..1b408a298a2 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -11,6 +11,7 @@ from homeassistant.components.knx.const import ( from homeassistant.components.knx.schema import SelectSchema from homeassistant.const import CONF_NAME, CONF_PAYLOAD, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from .conftest import KNXTestKit @@ -76,7 +77,7 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit) -> None assert state.state is STATE_UNKNOWN # select invalid option - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index f6a32a6ef35..b35fdf5c917 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -12,6 +12,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import setup_integration @@ -59,7 +60,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "10"} - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 3153005d094..a055e8fed05 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -15,6 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -84,7 +85,7 @@ async def test_select_invalid_option(hass: HomeAssistant) -> None: assert state assert state.state == "60" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index 585972a0953..604bf3f0fb9 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component @@ -111,8 +112,8 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() assert hass.states.get("select.select_1").state == "option 2" - # test ValueError trigger - with pytest.raises(ValueError): + # test ServiceValidationError trigger + with pytest.raises(ServiceValidationError) as exc: await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, @@ -120,11 +121,14 @@ async def test_custom_integration_and_validation( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "not_valid_option" + assert hass.states.get("select.select_1").state == "option 2" assert hass.states.get("select.select_2").state == STATE_UNKNOWN - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index a999f0e7c9a..794fbb090e0 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -31,6 +31,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import TEST_MAC @@ -77,7 +78,7 @@ async def test_select_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "forward" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", SERVICE_SELECT_OPTION, From d33ad57dd3d4ff32af6982eebd38d1e6936c3960 Mon Sep 17 00:00:00 2001 From: Joe Neuman Date: Wed, 27 Dec 2023 00:48:52 -0800 Subject: [PATCH 759/927] Add qBittorrent torrent sensors (#105781) * Upgrade QBittorrent integration to show torrents This brings the QBittorrent integration to be more in line with the Transmission integration. It updates how the integration is written, along with adding sensors for Active Torrents, Inactive Torrents, Paused Torrents, Total Torrents, Seeding Torrents, Started Torrents. * Remove unused stuff * Fix codeowners * Correct name in comments * Update __init__.py * Make get torrents a service with a response * Update sensor.py * Update sensor.py * Update sensor.py * Add new sensors * remove service * more removes * more * Address comments * cleanup * Update coordinator.py * Fix most lint issues * Update sensor.py * Update sensor.py * Update manifest.json * Update sensor class * Update sensor.py * Fix lint issue with sensor class * Adding codeowners * Update homeassistant/components/qbittorrent/__init__.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- .../components/qbittorrent/__init__.py | 29 ++-- homeassistant/components/qbittorrent/const.py | 4 + .../components/qbittorrent/coordinator.py | 2 +- .../components/qbittorrent/manifest.json | 2 +- .../components/qbittorrent/sensor.py | 143 ++++++++++++------ .../components/qbittorrent/strings.json | 31 ++++ 7 files changed, 151 insertions(+), 64 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b50af486033..b0dcda5ce27 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1020,8 +1020,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue -/homeassistant/components/qbittorrent/ @geoffreylagaisse -/tests/components/qbittorrent/ @geoffreylagaisse +/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 +/tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index fd9577f5c73..84315186097 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -19,21 +19,21 @@ from .const import DOMAIN from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client -PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + try: client = await hass.async_add_executor_job( setup_client, - entry.data[CONF_URL], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_URL], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: raise ConfigEntryNotReady("Invalid credentials") from err @@ -42,16 +42,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - hass.data[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: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload qBittorrent config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + del hass.data[DOMAIN][config_entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 0a79c67f400..96c60e9b380 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -5,3 +5,7 @@ DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" DEFAULT_URL = "http://127.0.0.1:8080" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8363a764d0a..11467ce62f4 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """QBittorrent update coordinator.""" + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index e2c1526e4f8..fb51f177081 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -1,7 +1,7 @@ { "domain": "qbittorrent", "name": "qBittorrent", - "codeowners": ["@geoffreylagaisse"], + "codeowners": ["@geoffreylagaisse", "@finder39"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 0e6bc071125..a51ff58405c 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -4,22 +4,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,62 +26,94 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_ALL_TORRENTS = "all_torrents" +SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" +SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" +SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" -@dataclass(frozen=True) -class QBittorrentMixin: - """Mixin for required keys.""" - - value_fn: Callable[[dict[str, Any]], StateType] - - -@dataclass(frozen=True) -class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): - """Describes QBittorrent sensor entity.""" - - -def _get_qbittorrent_state(data: dict[str, Any]) -> str: - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] +def get_state(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + upload = coordinator.data["server_state"]["up_info_speed"] + download = coordinator.data["server_state"]["dl_info_speed"] if upload > 0 and download > 0: - return "up_down" + return STATE_UP_DOWN if upload > 0 and download == 0: - return "seeding" + return STATE_SEEDING if upload == 0 and download > 0: - return "downloading" + return STATE_DOWNLOADING return STATE_IDLE -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +@dataclass(frozen=True, kw_only=True) +class QBittorrentSensorEntityDescription(SensorEntityDescription): + """Entity description class for qBittorent sensors.""" + + value_fn: Callable[[QBittorrentDataCoordinator], StateType] SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, - name="Status", - value_fn=_get_qbittorrent_state, + translation_key="current_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + value_fn=get_state, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, - name="Down Speed", + translation_key="download_speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["dl_info_speed"] + ), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, - name="Up Speed", + translation_key="upload_speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["up_info_speed"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + translation_key="all_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + translation_key="active_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["downloading", "uploading"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_INACTIVE_TORRENTS, + translation_key="inactive_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["stalledDL", "stalledUP"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_PAUSED_TORRENTS, + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["pausedDL", "pausedUP"] + ), ), ) @@ -90,36 +121,54 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entites: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities = [ - QBittorrentSensor(description, coordinator, config_entry) + + async_add_entities( + QBittorrentSensor(coordinator, config_entry, description) for description in SENSOR_TYPES - ] - async_add_entites(entities) + ) class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): """Representation of a qBittorrent sensor.""" + _attr_has_entity_name = True entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: QBittorrentSensorEntityDescription, coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, + entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" - self._attr_name = f"{config_entry.title} {description.name}" - self._attr_available = False + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="QBittorrent", + ) @property def native_value(self) -> StateType: - """Return value of sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator) + + +def count_torrents_in_states( + coordinator: QBittorrentDataCoordinator, states: list[str] +) -> int: + """Count the number of torrents in specified states.""" + return len( + [ + torrent + for torrent in coordinator.data["torrents"].values() + if torrent["state"] in states + ] + ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 66c9430911e..8b20a3354dd 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "transmission_status": { + "name": "Status", + "state": { + "idle": "[%key:common::state::idle%]", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + }, + "active_torrents": { + "name": "Active torrents" + }, + "inactive_torrents": { + "name": "Inactive torrents" + }, + "paused_torrents": { + "name": "Paused torrents" + }, + "all_torrents": { + "name": "All torrents" + } + } } } From d6d8e914bcdd2404ccb1911e23501d85b04169ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 09:52:16 +0100 Subject: [PATCH 760/927] Flip around Tailwind locked out binary sensor (#106457) --- .../components/tailwind/binary_sensor.py | 6 +- .../components/tailwind/strings.json | 8 +- .../snapshots/test_binary_sensor.ambr | 186 +++--------------- .../components/tailwind/test_binary_sensor.py | 4 +- 4 files changed, 32 insertions(+), 172 deletions(-) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 13a987bb998..eaa0cbd1a08 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from gotailwind import TailwindDoor from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -30,10 +31,11 @@ class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( TailwindDoorBinarySensorEntityDescription( key="locked_out", - translation_key="operational_status", + translation_key="operational_problem", entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:garage-alert", - is_on_fn=lambda door: not door.locked_out, + is_on_fn=lambda door: door.locked_out, ), ) diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index de5a025cbce..ab472a46739 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -47,11 +47,11 @@ }, "entity": { "binary_sensor": { - "operational_status": { - "name": "Operational status", + "operational_problem": { + "name": "Operational problem", "state": { - "off": "Locked out", - "on": "Operational" + "off": "Operational", + "on": "Locked out" } } }, diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 68a503e7fc0..aafd15501ee 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -1,18 +1,19 @@ # serializer version: 1 -# name: test_number_entities +# name: test_number_entities[binary_sensor.door_1_operational_problem] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Door 1 Operational status', + 'device_class': 'problem', + 'friendly_name': 'Door 1 Operational problem', 'icon': 'mdi:garage-alert', }), 'context': , - 'entity_id': 'binary_sensor.door_1_operational_status', + 'entity_id': 'binary_sensor.door_1_operational_problem', 'last_changed': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- -# name: test_number_entities.1 +# name: test_number_entities[binary_sensor.door_1_operational_problem].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +25,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.door_1_operational_status', + 'entity_id': 'binary_sensor.door_1_operational_problem', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,162 +33,18 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:garage-alert', - 'original_name': 'Operational status', + 'original_name': 'Operational problem', 'platform': 'tailwind', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'operational_status', + 'translation_key': 'operational_problem', 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', 'unit_of_measurement': None, }) # --- -# name: test_number_entities.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'tailwind', - '_3c_e9_e_6d_21_84_-door1', - ), - }), - 'is_new': False, - 'manufacturer': 'Tailwind', - 'model': 'iQ3', - 'name': 'Door 1', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '10.10', - 'via_device_id': None, - }) -# --- -# name: test_number_entities.3 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Door 2 Operational status', - 'icon': 'mdi:garage-alert', - }), - 'context': , - 'entity_id': 'binary_sensor.door_2_operational_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_number_entities.4 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.door_2_operational_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:garage-alert', - 'original_name': 'Operational status', - 'platform': 'tailwind', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'operational_status', - 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities.5 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'tailwind', - '_3c_e9_e_6d_21_84_-door2', - ), - }), - 'is_new': False, - 'manufacturer': 'Tailwind', - 'model': 'iQ3', - 'name': 'Door 2', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '10.10', - 'via_device_id': None, - }) -# --- -# name: test_number_entities[binary_sensor.door_1_operational_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Door 1 Operational status', - 'icon': 'mdi:garage-alert', - }), - 'context': , - 'entity_id': 'binary_sensor.door_1_operational_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_number_entities[binary_sensor.door_1_operational_status].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.door_1_operational_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:garage-alert', - 'original_name': 'Operational status', - 'platform': 'tailwind', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'operational_status', - 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities[binary_sensor.door_1_operational_status].2 +# name: test_number_entities[binary_sensor.door_1_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -215,20 +72,21 @@ 'via_device_id': , }) # --- -# name: test_number_entities[binary_sensor.door_2_operational_status] +# name: test_number_entities[binary_sensor.door_2_operational_problem] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Door 2 Operational status', + 'device_class': 'problem', + 'friendly_name': 'Door 2 Operational problem', 'icon': 'mdi:garage-alert', }), 'context': , - 'entity_id': 'binary_sensor.door_2_operational_status', + 'entity_id': 'binary_sensor.door_2_operational_problem', 'last_changed': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- -# name: test_number_entities[binary_sensor.door_2_operational_status].1 +# name: test_number_entities[binary_sensor.door_2_operational_problem].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -240,7 +98,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.door_2_operational_status', + 'entity_id': 'binary_sensor.door_2_operational_problem', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -248,18 +106,18 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:garage-alert', - 'original_name': 'Operational status', + 'original_name': 'Operational problem', 'platform': 'tailwind', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'operational_status', + 'translation_key': 'operational_problem', 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', 'unit_of_measurement': None, }) # --- -# name: test_number_entities[binary_sensor.door_2_operational_status].2 +# name: test_number_entities[binary_sensor.door_2_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py index 8715c143628..a2bb574986c 100644 --- a/tests/components/tailwind/test_binary_sensor.py +++ b/tests/components/tailwind/test_binary_sensor.py @@ -12,8 +12,8 @@ pytestmark = pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "entity_id", [ - "binary_sensor.door_1_operational_status", - "binary_sensor.door_2_operational_status", + "binary_sensor.door_1_operational_problem", + "binary_sensor.door_2_operational_problem", ], ) async def test_number_entities( From 1031e416beec1403a88697d3ed0e99ac809960c9 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 27 Dec 2023 09:56:13 +0100 Subject: [PATCH 761/927] Remove IP / mDNS validation in airq integration setup (#106326) Original design relied on aioairq.AirQ.__init__ checking if the input was a valid IP address or an mDNS of a very specific structure, and raising an InvalidInput otherwise. Now, aioairq==0.3.2 removes said check completely following a user's request to allow arbitrary host name and DNS entries. In the config flow, "cannot_connect" covers the cases of misspelled inputs now, which previously were covered by a dedicated "invalid_input" --- homeassistant/components/airq/config_flow.py | 52 ++++++++------------ homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airq/test_config_flow.py | 17 +------ 5 files changed, 24 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 41eda912e98..33d76ec75bc 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from aioairq import AirQ, InvalidAuth, InvalidInput +from aioairq import AirQ, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol @@ -42,44 +42,32 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} session = async_get_clientsession(self.hass) + airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) try: - airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) - except InvalidInput: + await airq.validate() + except ClientConnectionError: _LOGGER.debug( - "%s does not appear to be a valid IP address or mDNS name", + ( + "Failed to connect to device %s. Check the IP address / device" + " ID as well as whether the device is connected to power and" + " the WiFi" + ), user_input[CONF_IP_ADDRESS], ) - errors["base"] = "invalid_input" + errors["base"] = "cannot_connect" + except InvalidAuth: + _LOGGER.debug( + "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] + ) + errors["base"] = "invalid_auth" else: - try: - await airq.validate() - except ClientConnectionError: - _LOGGER.debug( - ( - "Failed to connect to device %s. Check the IP address / device" - " ID as well as whether the device is connected to power and" - " the WiFi" - ), - user_input[CONF_IP_ADDRESS], - ) - errors["base"] = "cannot_connect" - except InvalidAuth: - _LOGGER.debug( - "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] - ) - errors["base"] = "invalid_auth" - else: - _LOGGER.debug( - "Successfully connected to %s", user_input[CONF_IP_ADDRESS] - ) + _LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS]) - device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info["id"]) - self._abort_if_unique_id_configured() + device_info = await airq.fetch_device_info() + await self.async_set_unique_id(device_info["id"]) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title=device_info["name"], data=user_input - ) + return self.async_create_entry(title=device_info["name"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 156f167913b..2b23928aba8 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.3.1"] + "requirements": ["aioairq==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47a37387974..68f9686f561 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.3.1 +aioairq==0.3.2 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83df72a45c2..3fe7adabc2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.3.1 +aioairq==0.3.2 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 52fc8d2300b..1619440a6f7 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import pytest @@ -80,21 +80,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_invalid_input(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("aioairq.AirQ.validate", side_effect=InvalidInput): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_IP_ADDRESS: "invalid_ip"} - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_input"} - - async def test_duplicate_error(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( From 2b8fc8e5aeee56303e3b28e917e2f01be30f751b Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 27 Dec 2023 04:13:18 -0500 Subject: [PATCH 762/927] Update quality scale for Aladdin (#99342) update quality scale --- homeassistant/components/aladdin_connect/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 83f8e0167e8..344c77dcb73 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], + "quality_scale": "platinum", "requirements": ["AIOAladdinConnect==0.1.58"] } From 68ac4717dcf668aede91566dafc6ef60aedc6a91 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 10:22:37 +0100 Subject: [PATCH 763/927] Revert "Raise HomeAssistantError if event is triggered with invalid event_type" (#106458) --- homeassistant/components/event/__init__.py | 13 +------------ homeassistant/components/event/strings.json | 5 ----- homeassistant/components/mqtt/event.py | 3 +-- tests/components/event/test_init.py | 4 +--- 4 files changed, 3 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index fb94411fc36..b05c3a6f3a5 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Self, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -155,17 +154,7 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) ) -> None: """Process a new event.""" if event_type not in self.event_types: - event_types: str = ", ".join(self.event_types) - raise HomeAssistantError( - f"Invalid event type {event_type} for {self.entity_id}", - translation_key="invalid_event_type", - translation_domain=DOMAIN, - translation_placeholders={ - "event_type": event_type, - "event_types": event_types, - "entity_id": self.entity_id, - }, - ) + raise ValueError(f"Invalid event type {event_type} for {self.entity_id}") self.__last_event_triggered = dt_util.utcnow() self.__last_event_type = event_type self.__last_event_attributes = event_attributes diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index 2dd4089ded9..02f4da8ca08 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -21,10 +21,5 @@ "motion": { "name": "Motion" } - }, - "exceptions": { - "invalid_event_type": { - "message": "Invalid event type {event_type} for {entity_id}, valid types are: {event_types}." - } } } diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index b5e8e9000f7..c9302bf65b1 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -16,7 +16,6 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -175,7 +174,7 @@ class MqttEvent(MqttEntity, EventEntity): return try: self._trigger_event(event_type, event_attributes) - except HomeAssistantError: + except ValueError: _LOGGER.warning( "Invalid event type %s for %s received on topic %s, payload %s", event_type, diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 7d3e5e201e3..b8ba5fb6a18 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -16,7 +16,6 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component @@ -91,8 +90,7 @@ async def test_event() -> None: # Test triggering an unknown event with pytest.raises( - HomeAssistantError, - match="^Invalid event type unknown_event for event.doorbell$", + ValueError, match="^Invalid event type unknown_event for event.doorbell$" ): event._trigger_event("unknown_event") From a78ecb3895f9bdb189abeccb3a8b2b33f7c4f17a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 10:53:31 +0100 Subject: [PATCH 764/927] Add error handling to Tailwind service methods (#106463) --- homeassistant/components/tailwind/button.py | 12 ++- homeassistant/components/tailwind/cover.py | 66 +++++++++++-- homeassistant/components/tailwind/number.py | 12 ++- .../components/tailwind/strings.json | 11 +++ tests/components/tailwind/test_button.py | 17 ++++ tests/components/tailwind/test_cover.py | 96 ++++++++++++++++++- tests/components/tailwind/test_number.py | 20 ++++ 7 files changed, 219 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index dd9548d131c..019b803901c 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from gotailwind import Tailwind +from gotailwind import Tailwind, TailwindError from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,6 +15,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -62,4 +63,11 @@ class TailwindButtonEntity(TailwindEntity, ButtonEntity): async def async_press(self) -> None: """Trigger button press on the Tailwind device.""" - await self.entity_description.press_fn(self.coordinator.tailwind) + try: + await self.entity_description.press_fn(self.coordinator.tailwind) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 4280b6c4baf..935fa01eee0 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -3,7 +3,13 @@ from __future__ import annotations from typing import Any -from gotailwind import TailwindDoorOperationCommand, TailwindDoorState +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindDoorState, + TailwindError, +) from homeassistant.components.cover import ( CoverDeviceClass, @@ -12,6 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -56,11 +63,31 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): """ self._attr_is_opening = True self.async_write_ha_state() - await self.coordinator.tailwind.operate( - door=self.coordinator.data.doors[self.door_id], - operation=TailwindDoorOperationCommand.OPEN, - ) - self._attr_is_opening = False + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.OPEN, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + finally: + self._attr_is_opening = False await self.coordinator.async_request_refresh() async def async_close_cover(self, **kwargs: Any) -> None: @@ -71,9 +98,28 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): """ self._attr_is_closing = True self.async_write_ha_state() - await self.coordinator.tailwind.operate( - door=self.coordinator.data.doors[self.door_id], - operation=TailwindDoorOperationCommand.CLOSE, - ) + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.CLOSE, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc self._attr_is_closing = False await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 88940d110fa..5853e5c2d30 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -5,12 +5,13 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from gotailwind import Tailwind, TailwindDeviceStatus +from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -72,5 +73,12 @@ class TailwindNumberEntity(TailwindEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" - await self.entity_description.set_value_fn(self.coordinator.tailwind, value) + try: + await self.entity_description.set_value_fn(self.coordinator.tailwind, value) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index ab472a46739..7ff7fd439cc 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -60,5 +60,16 @@ "name": "Status LED brightness" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Tailwind device." + }, + "door_disabled": { + "message": "The door is disabled and cannot be operated." + }, + "door_locked_out": { + "message": "The door is locked out and cannot be operated." + } } } diff --git a/tests/components/tailwind/test_button.py b/tests/components/tailwind/test_button.py index 708816d733c..a0128d5f498 100644 --- a/tests/components/tailwind/test_button.py +++ b/tests/components/tailwind/test_button.py @@ -1,12 +1,15 @@ """Tests for button entities provided by the Tailwind integration.""" from unittest.mock import MagicMock +from gotailwind import TailwindError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.tailwind.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = [ @@ -46,3 +49,17 @@ async def test_number_entities( assert (state := hass.states.get(state.entity_id)) assert state.state == "2023-12-17T15:25:00+00:00" + + # Test error handling + mock_tailwind.identify.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py index e13ab534e5b..9620d6149b7 100644 --- a/tests/components/tailwind/test_cover.py +++ b/tests/components/tailwind/test_cover.py @@ -1,7 +1,12 @@ """Tests for cover entities provided by the Tailwind integration.""" from unittest.mock import ANY, MagicMock -from gotailwind import TailwindDoorOperationCommand +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindError, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -10,8 +15,10 @@ from homeassistant.components.cover import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, ) +from homeassistant.components.tailwind.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = pytest.mark.usefixtures("init_integration") @@ -74,3 +81,90 @@ async def test_cover_operations( mock_tailwind.operate.assert_called_with( door=ANY, operation=TailwindDoorOperationCommand.CLOSE ) + + # Test door disabled error handling + mock_tailwind.operate.side_effect = TailwindDoorDisabledError("Door disabled") + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + # Test door locked out error handling + mock_tailwind.operate.side_effect = TailwindDoorLockedOutError("Door locked out") + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + # Test door error handling + mock_tailwind.operate.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_number.py b/tests/components/tailwind/test_number.py index b67af3d0e62..e16c940b85d 100644 --- a/tests/components/tailwind/test_number.py +++ b/tests/components/tailwind/test_number.py @@ -1,13 +1,16 @@ """Tests for number entities provided by the Tailwind integration.""" from unittest.mock import MagicMock +from gotailwind import TailwindError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import number from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.tailwind.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = pytest.mark.usefixtures("init_integration") @@ -44,3 +47,20 @@ async def test_number_entities( assert len(mock_tailwind.status_led.mock_calls) == 1 mock_tailwind.status_led.assert_called_with(brightness=42) + + # Test error handling + mock_tailwind.status_led.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" From f7d482c85b6be8b4ed093295ed1f6e7f85a5c657 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Dec 2023 11:04:20 +0100 Subject: [PATCH 765/927] Fix Comelit alarm state (#106466) --- homeassistant/components/comelit/alarm_control_panel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 7954fc05dd1..33107dd3e82 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -127,15 +127,13 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel return STATE_ALARM_ARMED_NIGHT return STATE_ALARM_ARMED_HOME - { + return { AlarmAreaState.DISARMED: STATE_ALARM_DISARMED, AlarmAreaState.ENTRY_DELAY: STATE_ALARM_DISARMING, AlarmAreaState.EXIT_DELAY: STATE_ALARM_ARMING, AlarmAreaState.TRIGGERED: STATE_ALARM_TRIGGERED, }.get(self._area.human_status) - return None - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code != str(self._api.device_pin): From 675d4f48b528ddca5a3bc5e20896b07a9d9e634e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 27 Dec 2023 20:05:17 +1000 Subject: [PATCH 766/927] Fix update platform in Tessie (#106465) Return up-to-date --- homeassistant/components/tessie/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 457978f232f..1d2fb59c492 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -65,7 +65,7 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): TessieUpdateStatus.WIFI_WAIT, ): return self.get("vehicle_state_software_update_version") - return None + return self.installed_version @property def in_progress(self) -> bool | int | None: From 8fb5d5c299ae612a7eba3fddbad8dfd7820314b4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 11:30:10 +0100 Subject: [PATCH 767/927] Mark Tailwind a platinum quality integration (#106468) --- homeassistant/components/tailwind/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 014b5d3379a..da115ab5603 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["gotailwind==0.2.2"], "zeroconf": [ { From 4f38d8cc5dbadc11f3c28838bfff94869e668984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 27 Dec 2023 11:44:09 +0100 Subject: [PATCH 768/927] Bump millheater to 0.11.8 (#106464) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 7bb78eb05e7..16e7bf552ba 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.8", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68f9686f561..6f9b42daa70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1270,7 +1270,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.7 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fe7adabc2c..edd500c2bdd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -997,7 +997,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.7 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 From c19688e2d20532860711fbcf9530980761752c4b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 27 Dec 2023 11:47:52 +0100 Subject: [PATCH 769/927] Add preselect_remember_me to `/auth/providers` (#106462) --- homeassistant/components/auth/login_flow.py | 10 +- homeassistant/components/person/__init__.py | 21 ---- tests/components/auth/test_login_flow.py | 122 ++++++++------------ tests/components/person/test_init.py | 30 +---- 4 files changed, 57 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 9b96e57dbd3..cc6cb5fc47a 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,6 +91,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local from . import indieauth @@ -185,7 +186,14 @@ class AuthProvidersView(HomeAssistantView): } ) - return self.json(providers) + preselect_remember_me = not cloud_connection and is_local(remote_address) + + return self.json( + { + "providers": providers, + "preselect_remember_me": preselect_remember_me, + } + ) def _prepare_result_json( diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index c796cb8d843..49b719a5490 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,11 +1,9 @@ """Support for tracking people.""" from __future__ import annotations -from http import HTTPStatus import logging from typing import Any -from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -15,7 +13,6 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) -from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -388,8 +385,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) - hass.http.register_view(ListPersonsView) - return True @@ -574,19 +569,3 @@ def _get_latest(prev: State | None, curr: State): if prev is None or curr.last_updated > prev.last_updated: return curr return prev - - -class ListPersonsView(HomeAssistantView): - """List all persons if request is made from a local network.""" - - requires_auth = False - url = "/api/person/list" - name = "api:person:list" - - async def get(self, request: web.Request) -> web.Response: - """Return a list of persons if request comes from a local IP.""" - return self.json_message( - message="Not local", - status_code=HTTPStatus.BAD_REQUEST, - message_code="not_local", - ) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 27652ca2be4..c8b0261b79c 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from . import BASE_CONFIG, async_setup_auth @@ -26,22 +25,30 @@ _TRUSTED_NETWORKS_CONFIG = { @pytest.mark.parametrize( - ("provider_configs", "ip", "expected"), + ("ip", "preselect_remember_me"), + [ + ("192.168.1.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +@pytest.mark.parametrize( + ("provider_configs", "expected"), [ ( BASE_CONFIG, - None, [{"name": "Example", "type": "insecure_example", "id": None}], ), ( - [_TRUSTED_NETWORKS_CONFIG], - None, - [], - ), - ( - [_TRUSTED_NETWORKS_CONFIG], - "192.168.0.1", - [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + [{"type": "homeassistant"}], + [ + { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + } + ], ), ], ) @@ -49,8 +56,9 @@ async def test_fetch_auth_providers( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, provider_configs: list[dict[str, Any]], - ip: str | None, expected: list[dict[str, Any]], + ip: str, + preselect_remember_me: bool, ) -> None: """Test fetching auth providers.""" client = await async_setup_auth( @@ -58,73 +66,37 @@ async def test_fetch_auth_providers( ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == expected - - -async def _test_fetch_auth_providers_home_assistant( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, -) -> None: - """Test fetching auth providers for homeassistant auth provider.""" - client = await async_setup_auth( - hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip - ) - - expected = { - "name": "Home Assistant Local", - "type": "homeassistant", - "id": None, + assert await resp.json() == { + "providers": expected, + "preselect_remember_me": preselect_remember_me, } + +@pytest.mark.parametrize( + ("ip", "expected"), + [ + ( + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + ), + ("::ffff:192.168.0.10", []), + ("1.2.3.4", []), + ("2001:db8::1", []), + ], +) +async def test_fetch_auth_providers_trusted_network( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + expected: list[dict[str, Any]], + ip: str, +) -> None: + """Test fetching auth providers.""" + client = await async_setup_auth( + hass, aiohttp_client, [_TRUSTED_NETWORKS_CONFIG], custom_ip=ip + ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == [expected] - - -@pytest.mark.parametrize( - "ip", - [ - "192.168.0.10", - "::ffff:192.168.0.10", - "1.2.3.4", - "2001:db8::1", - ], -) -async def test_fetch_auth_providers_home_assistant_person_not_loaded( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, -) -> None: - """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" - await _test_fetch_auth_providers_home_assistant(hass, aiohttp_client, ip) - - -@pytest.mark.parametrize( - ("ip", "is_local"), - [ - ("192.168.0.10", True), - ("::ffff:192.168.0.10", True), - ("1.2.3.4", False), - ("2001:db8::1", False), - ], -) -async def test_fetch_auth_providers_home_assistant_person_loaded( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, - is_local: bool, -) -> None: - """Test fetching auth providers for homeassistant auth provider, where person integration is loaded.""" - domain = "person" - config = {domain: {"id": "1234", "name": "test person"}} - assert await async_setup_component(hass, domain, config) - - await _test_fetch_auth_providers_home_assistant( - hass, - aiohttp_client, - ip, - ) + assert (await resp.json())["providers"] == expected async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1866f682b55..71491ee3caf 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -30,7 +29,7 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -848,30 +847,3 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] - - -async def test_list_persons( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - hass_admin_user: MockUser, -) -> None: - """Test listing persons from a not local ip address.""" - - user_id = hass_admin_user.id - admin = {"id": "1234", "name": "Admin", "user_id": user_id, "picture": "/bla"} - config = { - DOMAIN: [ - admin, - {"id": "5678", "name": "Only a person"}, - ] - } - assert await async_setup_component(hass, DOMAIN, config) - - await async_setup_component(hass, "api", {}) - client = await hass_client_no_auth() - - resp = await client.get("/api/person/list") - - assert resp.status == HTTPStatus.BAD_REQUEST - result = await resp.json() - assert result == {"code": "not_local", "message": "Not local"} From 25f9c5f34b57602f39ebb8d9403429b2b067c24c Mon Sep 17 00:00:00 2001 From: kingy444 Date: Wed, 27 Dec 2023 22:38:37 +1100 Subject: [PATCH 770/927] Fix RainMachine bugs (#106231) --- homeassistant/components/rainmachine/__init__.py | 9 ++++++++- homeassistant/components/rainmachine/config_flow.py | 7 +++++++ homeassistant/components/rainmachine/const.py | 1 + homeassistant/components/rainmachine/strings.json | 3 ++- homeassistant/components/rainmachine/switch.py | 11 +++++++++-- tests/components/rainmachine/test_config_flow.py | 8 +++++++- tests/components/rainmachine/test_diagnostics.py | 13 +++++++++++-- 7 files changed, 45 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 411691ca9f5..5c3ff18f71c 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -38,6 +38,7 @@ from homeassistant.util.network import is_ip_address from .config_flow import get_client_controller from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -48,6 +49,7 @@ from .const import ( DATA_RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, + DEFAULT_ZONE_RUN, DOMAIN, LOGGER, ) @@ -249,8 +251,13 @@ async def async_setup_entry( # noqa: C901 **entry.options, CONF_DEFAULT_ZONE_RUN_TIME: data.pop(CONF_DEFAULT_ZONE_RUN_TIME), } + entry_updates["options"] = {**entry.options} if CONF_USE_APP_RUN_TIMES not in entry.options: - entry_updates["options"] = {**entry.options, CONF_USE_APP_RUN_TIMES: False} + entry_updates["options"][CONF_USE_APP_RUN_TIMES] = False + if CONF_DEFAULT_ZONE_RUN_TIME not in entry.options: + entry_updates["options"][CONF_DEFAULT_ZONE_RUN_TIME] = DEFAULT_ZONE_RUN + if CONF_ALLOW_INACTIVE_ZONES_TO_RUN not in entry.options: + entry_updates["options"][CONF_ALLOW_INACTIVE_ZONES_TO_RUN] = False if entry_updates: hass.config_entries.async_update_entry(entry, **entry_updates) diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 1ad97de7d0b..1d73ef3dd88 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DEFAULT_PORT, @@ -188,6 +189,12 @@ class RainMachineOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_APP_RUN_TIMES, default=self.config_entry.options.get(CONF_USE_APP_RUN_TIMES), ): bool, + vol.Optional( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, + default=self.config_entry.options.get( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN + ), + ): bool, } ), ) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index 00af0bd0b75..e28b2326b79 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -8,6 +8,7 @@ DOMAIN = "rainmachine" CONF_DURATION = "duration" CONF_DEFAULT_ZONE_RUN_TIME = "zone_run_time" CONF_USE_APP_RUN_TIMES = "use_app_run_times" +CONF_ALLOW_INACTIVE_ZONES_TO_RUN = "allow_inactive_zones_to_run" DATA_API_VERSIONS = "api.versions" DATA_MACHINE_FIRMWARE_UPDATE_STATUS = "machine.firmware_update_status" diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index ac2b86754e5..a564d33e777 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -24,7 +24,8 @@ "title": "Configure RainMachine", "data": { "zone_run_time": "Default zone run time (in seconds)", - "use_app_run_times": "Use zone run times from RainMachine app" + "use_app_run_times": "Use zone run times from the RainMachine app", + "allow_inactive_zones_to_run": "Allow disabled zones to be run manually" } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 0150f4cb600..b47396bc9e5 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RainMachineData, RainMachineEntity, async_update_programs_and_zones from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -300,7 +301,10 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch): The only way this could occur is if someone rapidly turns a disabled activity off right after turning it on. """ - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): raise HomeAssistantError( f"Cannot turn off an inactive program/zone: {self.name}" ) @@ -314,7 +318,10 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): self._attr_is_on = False self.async_write_ha_state() raise HomeAssistantError( diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5fa457bf771..631f1d5a3f8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -8,6 +8,7 @@ from regenmaschine.errors import RainMachineError from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DOMAIN, @@ -106,12 +107,17 @@ async def test_options_flow(hass: HomeAssistant, config, config_entry) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False}, + user_input={ + CONF_DEFAULT_ZONE_RUN_TIME: 600, + CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, } diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 47cb3202026..2180bf2a20e 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -2,6 +2,7 @@ from regenmaschine.errors import RainMachineError from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.rainmachine.const import DEFAULT_ZONE_RUN from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -28,7 +29,11 @@ async def test_entry_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", @@ -655,7 +660,11 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", From dae8c0fc3824fa9cb3275ad3d7ef1359cbd1802b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 27 Dec 2023 12:48:53 +0100 Subject: [PATCH 771/927] Fix MQTT retained event messages should be discarded (#106425) --- homeassistant/components/mqtt/event.py | 12 ++++- tests/components/mqtt/test_event.py | 62 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c9302bf65b1..351eb422edc 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -35,7 +35,6 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entity_entry_helper, - write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -43,6 +42,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -120,9 +120,15 @@ class MqttEvent(MqttEntity, EventEntity): @callback @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" + if msg.retain: + _LOGGER.debug( + "Ignoring event trigger from replayed retained payload '%s' on topic %s", + msg.payload, + msg.topic, + ) + return event_attributes: dict[str, Any] = {} event_type: str payload = self._template(msg.payload, PayloadSentinel.DEFAULT) @@ -183,6 +189,8 @@ class MqttEvent(MqttEntity, EventEntity): payload, ) return + mqtt_data = get_mqtt_data(self.hass) + mqtt_data.state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index e178eb40c0e..1a75d61c733 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -88,6 +88,68 @@ async def test_setting_event_value_via_mqtt_message( assert state.attributes.get("duration") == "short" +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_multiple_events_are_all_updating_the_state( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test all events are respected and trigger a state write.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_handling_retained_event_payloads( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test if event messages with a retained flag are ignored.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 0 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + @pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @pytest.mark.parametrize( From 8cd0644035054f4779b1c8ea1b894936e93f39e1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Dec 2023 12:50:27 +0100 Subject: [PATCH 772/927] Add translation to ServiceValidationError in Lock (#105746) --- homeassistant/components/lock/__init__.py | 13 ++++++- homeassistant/components/lock/strings.json | 5 +++ tests/components/lock/test_init.py | 43 ++++++++++++++-------- tests/components/matter/test_door_lock.py | 3 +- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ec462c3b993..9a2466e22dd 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -152,8 +153,16 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if not code: code = self._lock_option_default_code if self.code_format_cmp and not self.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}" + if TYPE_CHECKING: + assert self.code_format + raise ServiceValidationError( + f"The code for {self.entity_id} doesn't match pattern {self.code_format}", + translation_domain=DOMAIN, + translation_key="add_default_code", + translation_placeholders={ + "entity_id": self.entity_id, + "code_format": self.code_format, + }, ) if code: data[ATTR_CODE] = code diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index d041d6ac61a..152a06f9e53 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -66,5 +66,10 @@ } } } + }, + "exceptions": { + "add_default_code": { + "message": "The code for {entity_id} doesn't match pattern {code_format}." + } } } diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index a03d975ed8a..c4337c367a9 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -1,6 +1,7 @@ """The tests for the lock component.""" from __future__ import annotations +import re from typing import Any import pytest @@ -21,6 +22,7 @@ from homeassistant.components.lock import ( LockEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -134,15 +136,15 @@ async def test_lock_open_with_code( state = hass.states.get(mock_lock_entity.entity_id) assert state.attributes["code_format"] == r"^\d{4}$" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_OPEN ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="HELLO" ) @@ -170,15 +172,15 @@ async def test_lock_lock_with_code( mock_lock_entity.calls_unlock.assert_called_with(code="1234") assert mock_lock_entity.calls_lock.call_count == 0 - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_LOCK ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="HELLO" ) @@ -206,15 +208,15 @@ async def test_lock_unlock_with_code( mock_lock_entity.calls_lock.assert_called_with(code="1234") assert mock_lock_entity.calls_unlock.call_count == 0 - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="HELLO" ) @@ -234,15 +236,15 @@ async def test_lock_with_illegal_code( ) -> None: """Test lock entity with default code that does not match the code format.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="123456" ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="123456" ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="123456" ) @@ -344,19 +346,30 @@ async def test_lock_with_illegal_default_code( assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} assert mock_lock_entity._lock_option_default_code == "" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_OPEN ) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_LOCK ) - with pytest.raises(ValueError): + with pytest.raises( + ServiceValidationError, + match=re.escape( + rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" + ), + ) as exc: await help_test_async_lock_service( hass, mock_lock_entity.entity_id, SERVICE_UNLOCK ) + assert ( + str(exc.value) + == rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" + ) + assert exc.value.translation_key == "add_default_code" + @pytest.mark.parametrize(("enum"), list(LockEntityFeature)) def test_deprecated_constants( diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index 991d23f3353..51d48cddba7 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -113,7 +114,7 @@ async def test_lock_requires_pin( # set door state to unlocked set_node_attribute(door_lock, 1, 257, 0, 2) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Lock door using invalid code format await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( From 9944047b352d99f6bca0e2f4898bfae725ce37a3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Dec 2023 12:51:24 +0100 Subject: [PATCH 773/927] Add typing to config flow A (#105721) --- homeassistant/components/aemet/config_flow.py | 7 +++- .../components/agent_dvr/config_flow.py | 21 +++++----- .../components/alarmdecoder/config_flow.py | 38 ++++++++++++++----- .../components/ambiclimate/config_flow.py | 26 ++++++++----- homeassistant/components/atag/config_flow.py | 9 ++++- .../components/august/config_flow.py | 16 +++++--- .../components/aurora/config_flow.py | 6 ++- homeassistant/components/aws/config_flow.py | 6 ++- 8 files changed, 88 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index dbf3df823e3..a58faaf6f6b 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,6 +1,8 @@ """Config flow for AEMET OpenData.""" from __future__ import annotations +from typing import Any + from aemet_opendata.exceptions import AuthError from aemet_opendata.interface import AEMET, ConnectionOptions import voluptuous as vol @@ -8,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -29,7 +32,9 @@ OPTIONS_FLOW = { class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for AEMET OpenData.""" - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index 8da3a497ceb..9143d40352f 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure Agent devices.""" from contextlib import suppress +from typing import Any from agent import AgentConnectionError, AgentError from agent.a import Agent @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, SERVER_URL @@ -18,11 +20,9 @@ DEFAULT_PORT = 8090 class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Agent config flow.""" - def __init__(self): - """Initialize the Agent config flow.""" - self.device_config = {} - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle an Agent config flow.""" errors = {} @@ -49,13 +49,15 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - self.device_config = { + device_config = { CONF_HOST: host, CONF_PORT: port, SERVER_URL: server_origin, } - return await self._create_entry(agent_client.name) + return self.async_create_entry( + title=agent_client.name, data=device_config + ) errors["base"] = "cannot_connect" @@ -66,11 +68,6 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - description_placeholders=self.device_config, data_schema=vol.Schema(data), errors=errors, ) - - async def _create_entry(self, server_name): - """Create entry for device.""" - return self.async_create_entry(title=server_name, data=self.device_config) diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 9a4b9ae1098..1b2bcf083ba 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from adext import AdExt from alarmdecoder.devices import SerialDevice, SocketDevice @@ -12,8 +13,10 @@ from homeassistant import config_entries from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ALT_NIGHT_MODE, @@ -66,7 +69,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for AlarmDecoder.""" return AlarmDecoderOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: self.protocol = user_input[CONF_PROTOCOL] @@ -83,7 +88,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_protocol(self, user_input=None): + async def async_step_protocol( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle AlarmDecoder protocol setup.""" errors = {} if user_input is not None: @@ -146,15 +153,18 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): """Handle AlarmDecoder options.""" + selected_zone: str | None = None + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AlarmDecoder options flow.""" self.arm_options = config_entry.options.get(OPTIONS_ARM, DEFAULT_ARM_OPTIONS) self.zone_options = config_entry.options.get( OPTIONS_ZONES, DEFAULT_ZONE_OPTIONS ) - self.selected_zone = None - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: if user_input[EDIT_KEY] == EDIT_SETTINGS: @@ -173,7 +183,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ), ) - async def async_step_arm_settings(self, user_input=None): + async def async_step_arm_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Arming options form.""" if user_input is not None: return self.async_create_entry( @@ -200,7 +212,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ), ) - async def async_step_zone_select(self, user_input=None): + async def async_step_zone_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone selection form.""" errors = _validate_zone_input(user_input) @@ -216,7 +230,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): errors=errors, ) - async def async_step_zone_details(self, user_input=None): + async def async_step_zone_details( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone details form.""" errors = _validate_zone_input(user_input) @@ -293,7 +309,7 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ) -def _validate_zone_input(zone_input): +def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]: if not zone_input: return {} errors = {} @@ -327,7 +343,7 @@ def _validate_zone_input(zone_input): return errors -def _fix_input_types(zone_input): +def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]: """Convert necessary keys to int. Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as @@ -341,7 +357,9 @@ def _fix_input_types(zone_input): return zone_input -def _device_already_added(current_entries, user_input, protocol): +def _device_already_added( + current_entries: list[ConfigEntry], user_input: dict[str, Any], protocol: str | None +) -> bool: """Determine if entry has already been added to HA.""" user_host = user_input.get(CONF_HOST) user_port = user_input.get(CONF_PORT) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 0d259cf337a..3d05ab2bb07 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Ambiclimate.""" import logging +from typing import Any from aiohttp import web import ambiclimate @@ -7,7 +8,8 @@ import ambiclimate from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -26,7 +28,9 @@ _LOGGER = logging.getLogger(__name__) @callback -def register_flow_implementation(hass, client_id, client_secret): +def register_flow_implementation( + hass: HomeAssistant, client_id: str, client_secret: str +) -> None: """Register a ambiclimate implementation. client_id: Client id. @@ -50,7 +54,9 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._registered_view = False self._oauth = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle external yaml configuration.""" self._async_abort_entries_match() @@ -62,7 +68,9 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_auth() - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" self._async_abort_entries_match() @@ -83,7 +91,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_code(self, code=None): + async def async_step_code(self, code: str | None = None) -> FlowResult: """Received code for authentication.""" self._async_abort_entries_match() @@ -95,7 +103,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="Ambiclimate", data=config) - async def _get_token_info(self, code): + async def _get_token_info(self, code: str | None) -> dict[str, Any] | None: oauth = self._generate_oauth() try: token_info = await oauth.get_access_token(code) @@ -103,16 +111,16 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Failed to get access token") return None - store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY) await store.async_save(token_info) return token_info - def _generate_view(self): + def _generate_view(self) -> None: self.hass.http.register_view(AmbiclimateAuthCallbackView()) self._registered_view = True - def _generate_oauth(self): + def _generate_oauth(self) -> ambiclimate.AmbiclimateOAuth: config = self.hass.data[DATA_AMBICLIMATE_IMPL] clientsession = async_get_clientsession(self.hass) callback_url = self._cb_url() diff --git a/homeassistant/components/atag/config_flow.py b/homeassistant/components/atag/config_flow.py index ecebec717f4..8dd7020acfb 100644 --- a/homeassistant/components/atag/config_flow.py +++ b/homeassistant/components/atag/config_flow.py @@ -1,9 +1,12 @@ """Config flow for the Atag component.""" +from typing import Any + import pyatag import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN @@ -19,7 +22,9 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if not user_input: @@ -39,7 +44,7 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=atag.id, data=user_input) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 0028db55415..f22b16008d3 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -80,20 +80,24 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True - self._mode = None + self._mode: str | None = None super().__init__() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" return await self.async_step_user_validate() - async def async_step_user_validate(self, user_input=None): + async def async_step_user_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle authentication.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -177,7 +181,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._needs_reset = True return await self.async_step_reauth_validate() - async def async_step_reauth_validate(self, user_input=None): + async def async_step_reauth_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauth and validation.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 8fa4b285758..95e66ff226e 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from aiohttp import ClientError from auroranoaa import AuroraForecast @@ -10,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -45,7 +47,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py index 1854afc6231..e0829ef2914 100644 --- a/homeassistant/components/aws/config_flow.py +++ b/homeassistant/components/aws/config_flow.py @@ -1,6 +1,10 @@ """Config flow for AWS component.""" +from collections.abc import Mapping +from typing import Any + from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -10,7 +14,7 @@ class AWSFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: Mapping[str, Any]) -> FlowResult: """Import a config entry.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") From b935facec8f8da4ffcfc03c55164f96539ab9743 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 27 Dec 2023 12:54:41 +0100 Subject: [PATCH 774/927] Add coordinator to Swiss public transport (#106278) --- .coveragerc | 1 + .../swiss_public_transport/__init__.py | 5 +- .../swiss_public_transport/const.py | 1 - .../swiss_public_transport/coordinator.py | 81 +++++++++++++++++ .../swiss_public_transport/sensor.py | 89 +++++-------------- 5 files changed, 110 insertions(+), 67 deletions(-) create mode 100644 homeassistant/components/swiss_public_transport/coordinator.py diff --git a/.coveragerc b/.coveragerc index ee8e165c9b6..44e424260c1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1257,6 +1257,7 @@ omit = homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/__init__.py + homeassistant/components/swiss_public_transport/coordinator.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 37f1eeb6765..9e01a07416f 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -13,6 +13,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_DESTINATION, CONF_START, DOMAIN +from .coordinator import SwissPublicTransportDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -48,7 +49,9 @@ async def async_setup_entry( f"Setup failed for entry '{start} {destination}' with invalid data" ) from e - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = opendata + coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) + 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 diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index d14a77feb2a..6d9fb8bb960 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -2,7 +2,6 @@ DOMAIN = "swiss_public_transport" - CONF_DESTINATION = "to" CONF_START = "from" diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py new file mode 100644 index 00000000000..93b3312b099 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for the swiss_public_transport integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import OpendataTransportError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DataConnection(TypedDict): + """A connection data class.""" + + departure: str + next_departure: str + next_on_departure: str + duration: str + platform: str + remaining_time: str + start: str + destination: str + train_number: str + transfers: str + delay: int + + +class SwissPublicTransportDataUpdateCoordinator(DataUpdateCoordinator[DataConnection]): + """A SwissPublicTransport Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: + """Initialize the SwissPublicTransport data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + ) + self._opendata = opendata + + async def _async_update_data(self) -> DataConnection: + try: + await self._opendata.async_get_data() + except OpendataTransportError as e: + _LOGGER.warning( + "Unable to connect and retrieve data from transport.opendata.ch" + ) + raise UpdateFailed from e + + departure_time = dt_util.parse_datetime( + self._opendata.connections[0]["departure"] + ) + if departure_time: + remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) + else: + remaining_time = None + + return DataConnection( + departure=self._opendata.connections[0]["departure"], + next_departure=self._opendata.connections[1]["departure"], + next_on_departure=self._opendata.connections[2]["departure"], + train_number=self._opendata.connections[0]["number"], + platform=self._opendata.connections[0]["platform"], + transfers=self._opendata.connections[0]["transfers"], + duration=self._opendata.connections[0]["duration"], + start=self._opendata.from_name, + destination=self._opendata.to_name, + remaining_time=f"{remaining_time}", + delay=self._opendata.connections[0]["delay"], + ) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index bc03b8d61e1..63b5891e48d 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -1,44 +1,30 @@ """Support for transport.opendata.ch.""" from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any -from opendata_transport import OpendataTransport -from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN, PLACEHOLDERS +from .coordinator import SwissPublicTransportDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=90) -ATTR_DEPARTURE_TIME1 = "next_departure" -ATTR_DEPARTURE_TIME2 = "next_on_departure" -ATTR_DURATION = "duration" -ATTR_PLATFORM = "platform" -ATTR_REMAINING_TIME = "remaining_time" -ATTR_START = "start" -ATTR_TARGET = "destination" -ATTR_TRAIN_NUMBER = "train_number" -ATTR_TRANSFERS = "transfers" -ATTR_DELAY = "delay" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DESTINATION): cv.string, @@ -54,12 +40,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" - opendata = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] name = config_entry.title async_add_entities( - [SwissPublicTransportSensor(opendata, name)], + [SwissPublicTransportSensor(coordinator, name)], True, ) @@ -108,60 +94,33 @@ async def async_setup_platform( ) -class SwissPublicTransportSensor(SensorEntity): +class SwissPublicTransportSensor( + CoordinatorEntity[SwissPublicTransportDataUpdateCoordinator], SensorEntity +): """Implementation of a Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" - def __init__(self, opendata: OpendataTransport, name: str) -> None: + def __init__( + self, coordinator: SwissPublicTransportDataUpdateCoordinator, name: str + ) -> None: """Initialize the sensor.""" - self._opendata = opendata + super().__init__(coordinator) + self._coordinator = coordinator self._attr_name = name - self._remaining_time: timedelta | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle the state update and prepare the extra state attributes.""" + self._attr_extra_state_attributes = { + key: value + for key, value in self.coordinator.data.items() + if key not in {"departure"} + } + return super()._handle_coordinator_update() @property def native_value(self) -> str: """Return the state of the sensor.""" - return self._opendata.connections[0]["departure"] - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return the state attributes.""" - departure_time = dt_util.parse_datetime( - self._opendata.connections[0]["departure"] - ) - if departure_time: - remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) - else: - remaining_time = None - self._remaining_time = remaining_time - - return { - ATTR_TRAIN_NUMBER: self._opendata.connections[0]["number"], - ATTR_PLATFORM: self._opendata.connections[0]["platform"], - ATTR_TRANSFERS: self._opendata.connections[0]["transfers"], - ATTR_DURATION: self._opendata.connections[0]["duration"], - ATTR_DEPARTURE_TIME1: self._opendata.connections[1]["departure"], - ATTR_DEPARTURE_TIME2: self._opendata.connections[2]["departure"], - ATTR_START: self._opendata.from_name, - ATTR_TARGET: self._opendata.to_name, - ATTR_REMAINING_TIME: f"{self._remaining_time}", - ATTR_DELAY: self._opendata.connections[0]["delay"], - } - - async def async_update(self) -> None: - """Get the latest data from opendata.ch and update the states.""" - - try: - if not self._remaining_time or self._remaining_time.total_seconds() < 0: - await self._opendata.async_get_data() - except OpendataTransportError: - self._attr_available = False - _LOGGER.warning( - "Unable to connect and retrieve data from transport.opendata.ch" - ) - else: - if not self._attr_available: - self._attr_available = True - _LOGGER.info("Connection established with transport.opendata.ch") + return self.coordinator.data["departure"] From f71e01f652472dbbc9b5311475e9c2da4e83bde5 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 27 Dec 2023 13:55:10 +0200 Subject: [PATCH 775/927] Improve glances tests (#106402) --- tests/components/glances/__init__.py | 4 +- tests/components/glances/test_sensor.py | 60 +++++++++++++++---------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 41f2675c41c..91f8da92799 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -181,8 +181,8 @@ HA_SENSOR_DATA: dict[str, Any] = { }, "sensors": { "cpu_thermal 1": {"temperature_core": 59}, - "err_temp": {"temperature_hdd": "Unavailable"}, - "na_temp": {"temperature_hdd": "Unavailable"}, + "err_temp": {"temperature_hdd": "unavailable"}, + "na_temp": {"temperature_hdd": "unavailable"}, }, "mem": { "memory_use_percent": 27.6, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 095c034abe0..af00126b219 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -19,30 +19,42 @@ async def test_sensor_states(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) - if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): - assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): - assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] - if state := hass.states.get("sensor.0_0_0_0_err_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["err_temp"] - if state := hass.states.get("sensor.0_0_0_0_na_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["na_temp"] - if state := hass.states.get("sensor.0_0_0_0_memory_use_percent"): - assert state.state == HA_SENSOR_DATA["mem"]["memory_use_percent"] - if state := hass.states.get("sensor.0_0_0_0_docker_active"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_active"] - if state := hass.states.get("sensor.0_0_0_0_docker_cpu_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] - if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] - if state := hass.states.get("sensor.0_0_0_0_md3_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md3_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["used"] - if state := hass.states.get("sensor.0_0_0_0_md1_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md1_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["used"] + assert hass.states.get("sensor.0_0_0_0_ssl_used").state == str( + HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + ) + assert hass.states.get("sensor.0_0_0_0_cpu_thermal_1_temperature").state == str( + HA_SENSOR_DATA["sensors"]["cpu_thermal 1"]["temperature_core"] + ) + assert hass.states.get("sensor.0_0_0_0_err_temp_temperature").state == str( + HA_SENSOR_DATA["sensors"]["err_temp"]["temperature_hdd"] + ) + assert hass.states.get("sensor.0_0_0_0_na_temp_temperature").state == str( + HA_SENSOR_DATA["sensors"]["na_temp"]["temperature_hdd"] + ) + assert hass.states.get("sensor.0_0_0_0_ram_used_percent").state == str( + HA_SENSOR_DATA["mem"]["memory_use_percent"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_active").state == str( + HA_SENSOR_DATA["docker"]["docker_active"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_cpu_used").state == str( + HA_SENSOR_DATA["docker"]["docker_cpu_use"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_ram_used").state == str( + HA_SENSOR_DATA["docker"]["docker_memory_use"] + ) + assert hass.states.get("sensor.0_0_0_0_md3_raid_available").state == str( + HA_SENSOR_DATA["raid"]["md3"]["available"] + ) + assert hass.states.get("sensor.0_0_0_0_md3_raid_used").state == str( + HA_SENSOR_DATA["raid"]["md3"]["used"] + ) + assert hass.states.get("sensor.0_0_0_0_md1_raid_available").state == str( + HA_SENSOR_DATA["raid"]["md1"]["available"] + ) + assert hass.states.get("sensor.0_0_0_0_md1_raid_used").state == str( + HA_SENSOR_DATA["raid"]["md1"]["used"] + ) @pytest.mark.parametrize( From d747b0891df11666c23689fb0e865a1222a9c026 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 27 Dec 2023 12:57:30 +0100 Subject: [PATCH 776/927] Add significant Change support for fan (#105867) --- .../components/fan/significant_change.py | 55 +++++++++++++++++++ .../components/fan/test_significant_change.py | 51 +++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 homeassistant/components/fan/significant_change.py create mode 100644 tests/components/fan/test_significant_change.py diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py new file mode 100644 index 00000000000..19c43522f35 --- /dev/null +++ b/homeassistant/components/fan/significant_change.py @@ -0,0 +1,55 @@ +"""Helper to test significant Fan state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP + +INSIGNIFICANT_ATTRIBUTES: set[str] = {ATTR_PERCENTAGE_STEP} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name in INSIGNIFICANT_ATTRIBUTES: + continue + + if attr_name != ATTR_PERCENTAGE: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/fan/test_significant_change.py b/tests/components/fan/test_significant_change.py new file mode 100644 index 00000000000..764abb6e8ee --- /dev/null +++ b/tests/components/fan/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Fan significant change platform.""" +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, +) +from homeassistant.components.fan.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Fan significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_PERCENTAGE_STEP: "1"}, {ATTR_PERCENTAGE_STEP: "2"}, False), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 2}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 1.9}, False), + ({ATTR_PERCENTAGE: "invalid"}, {ATTR_PERCENTAGE: 1}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: "invalid"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "front"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "back"}, True), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: True}, False), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: False}, True), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "auto"}, False), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "whoosh"}, True), + ( + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: True}, + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: False}, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Fan significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 0824a1f4a2de679ccfbc036d5c270a32c728db90 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 27 Dec 2023 12:59:33 +0100 Subject: [PATCH 777/927] Add significant Change support for media player (#105999) --- .../media_player/significant_change.py | 71 ++++++++++ .../media_player/test_significant_change.py | 126 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 homeassistant/components/media_player/significant_change.py create mode 100644 tests/components/media_player/test_significant_change.py diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py new file mode 100644 index 00000000000..b2a2e57d84f --- /dev/null +++ b/homeassistant/components/media_player/significant_change.py @@ -0,0 +1,71 @@ +"""Helper to test significant Media Player state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_TO_PROPERTY, +) + +INSIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, +} + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_GROUP_MEMBERS, + *ATTR_TO_PROPERTY, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES: + continue + + if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 0.1): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/media_player/test_significant_change.py b/tests/components/media_player/test_significant_change.py new file mode 100644 index 00000000000..1b0ac6fe5aa --- /dev/null +++ b/tests/components/media_player/test_significant_change.py @@ -0,0 +1,126 @@ +"""Test the Media Player significant change platform.""" +import pytest + +from homeassistant.components.media_player import ( + ATTR_APP_ID, + ATTR_APP_NAME, + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_GROUP_MEMBERS, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, +) +from homeassistant.components.media_player.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Media Player significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "old_value"}, False), + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "new_value"}, True), + ({ATTR_APP_NAME: "old_value"}, {ATTR_APP_NAME: "new_value"}, True), + ( + {ATTR_ENTITY_PICTURE_LOCAL: "old_value"}, + {ATTR_ENTITY_PICTURE_LOCAL: "new_value"}, + True, + ), + ({ATTR_GROUP_MEMBERS: "old_value"}, {ATTR_GROUP_MEMBERS: "new_value"}, True), + ({ATTR_INPUT_SOURCE: "old_value"}, {ATTR_INPUT_SOURCE: "new_value"}, True), + ( + {ATTR_MEDIA_ALBUM_ARTIST: "old_value"}, + {ATTR_MEDIA_ALBUM_ARTIST: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_ALBUM_NAME: "old_value"}, + {ATTR_MEDIA_ALBUM_NAME: "new_value"}, + True, + ), + ({ATTR_MEDIA_ARTIST: "old_value"}, {ATTR_MEDIA_ARTIST: "new_value"}, True), + ({ATTR_MEDIA_CHANNEL: "old_value"}, {ATTR_MEDIA_CHANNEL: "new_value"}, True), + ( + {ATTR_MEDIA_CONTENT_ID: "old_value"}, + {ATTR_MEDIA_CONTENT_ID: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_CONTENT_TYPE: "old_value"}, + {ATTR_MEDIA_CONTENT_TYPE: "new_value"}, + True, + ), + ({ATTR_MEDIA_DURATION: "old_value"}, {ATTR_MEDIA_DURATION: "new_value"}, True), + ({ATTR_MEDIA_EPISODE: "old_value"}, {ATTR_MEDIA_EPISODE: "new_value"}, True), + ({ATTR_MEDIA_PLAYLIST: "old_value"}, {ATTR_MEDIA_PLAYLIST: "new_value"}, True), + ({ATTR_MEDIA_REPEAT: "old_value"}, {ATTR_MEDIA_REPEAT: "new_value"}, True), + ({ATTR_MEDIA_SEASON: "old_value"}, {ATTR_MEDIA_SEASON: "new_value"}, True), + ( + {ATTR_MEDIA_SERIES_TITLE: "old_value"}, + {ATTR_MEDIA_SERIES_TITLE: "new_value"}, + True, + ), + ({ATTR_MEDIA_SHUFFLE: "old_value"}, {ATTR_MEDIA_SHUFFLE: "new_value"}, True), + ({ATTR_MEDIA_TITLE: "old_value"}, {ATTR_MEDIA_TITLE: "new_value"}, True), + ({ATTR_MEDIA_TRACK: "old_value"}, {ATTR_MEDIA_TRACK: "new_value"}, True), + ( + {ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_MEDIA_VOLUME_MUTED: "new_value"}, + True, + ), + ({ATTR_SOUND_MODE: "old_value"}, {ATTR_SOUND_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_SOUND_MODE: "old_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_SOUND_MODE: "new_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + True, + ), + # float attributes + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.2}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.19}, False), + ({ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, {ATTR_MEDIA_VOLUME_LEVEL: 1}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 1}, {ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_MEDIA_POSITION: "old_value"}, {ATTR_MEDIA_POSITION: "new_value"}, False), + ( + {ATTR_MEDIA_POSITION_UPDATED_AT: "old_value"}, + {ATTR_MEDIA_POSITION_UPDATED_AT: "new_value"}, + False, + ), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Media Player significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From bd4177edc804af96b27e9ed84fa35cd401e213ac Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Wed, 27 Dec 2023 13:01:20 +0100 Subject: [PATCH 778/927] Add supports_response to ServiceRegistry.register() (#106164) Co-authored-by: rikroe --- homeassistant/core.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index fc0bc5ebe5a..72287fb81ce 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1970,13 +1970,20 @@ class ServiceRegistry: Coroutine[Any, Any, ServiceResponse] | ServiceResponse | None, ], schema: vol.Schema | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service. Schema is called to coerce and validate the service data. """ run_callback_threadsafe( - self._hass.loop, self.async_register, domain, service, service_func, schema + self._hass.loop, + self.async_register, + domain, + service, + service_func, + schema, + supports_response, ).result() @callback From d0b6acd5e047881f6b97e8cbaab672d259f20a72 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 27 Dec 2023 13:04:49 +0100 Subject: [PATCH 779/927] Changed setup of easyEnergy services (#106288) --- .../components/easyenergy/__init__.py | 13 ++++- .../components/easyenergy/services.py | 56 ++++++++++++++---- .../components/easyenergy/services.yaml | 15 +++++ .../components/easyenergy/strings.json | 18 ++++++ tests/components/easyenergy/test_services.py | 57 +++++++++++++++++-- 5 files changed, 139 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index 6c00ec5a6a3..e941c78b1fb 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -5,12 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EasyEnergyDataUpdateCoordinator from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the easyEnergy services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -27,8 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_setup_services(hass, coordinator) - return True diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 777fa4280b2..a68dfcb791c 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -9,6 +9,7 @@ from typing import Final from easyenergy import Electricity, Gas, VatOption import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,11 +18,13 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import EasyEnergyDataUpdateCoordinator +ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" ATTR_END: Final = "end" ATTR_INCL_VAT: Final = "incl_vat" @@ -31,6 +34,11 @@ ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" SERVICE_SCHEMA: Final = vol.Schema( { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), vol.Required(ATTR_INCL_VAT): bool, vol.Optional(ATTR_START): str, vol.Optional(ATTR_END): str, @@ -77,13 +85,44 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp } +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EasyEnergyDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + async def __get_prices( call: ServiceCall, *, - coordinator: EasyEnergyDataUpdateCoordinator, + hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: """Get prices from easyEnergy.""" + coordinator = __get_coordinator(hass, call) + start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -112,34 +151,27 @@ async def __get_prices( @callback -def async_setup_services( - hass: HomeAssistant, - coordinator: EasyEnergyDataUpdateCoordinator, -) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for easyEnergy integration.""" hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, coordinator=coordinator, price_type=PriceType.GAS), + partial(__get_prices, hass=hass, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_USAGE_SERVICE_NAME, - partial( - __get_prices, coordinator=coordinator, price_type=PriceType.ENERGY_USAGE - ), + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_RETURN_SERVICE_NAME, - partial( - __get_prices, coordinator=coordinator, price_type=PriceType.ENERGY_RETURN - ), + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/easyenergy/services.yaml b/homeassistant/components/easyenergy/services.yaml index 01b78431afb..63187256f00 100644 --- a/homeassistant/components/easyenergy/services.yaml +++ b/homeassistant/components/easyenergy/services.yaml @@ -1,5 +1,10 @@ get_gas_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy incl_vat: required: true default: true @@ -17,6 +22,11 @@ get_gas_prices: datetime: get_energy_usage_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy incl_vat: required: true default: true @@ -34,6 +44,11 @@ get_energy_usage_prices: datetime: get_energy_return_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy start: required: false example: "2024-01-01 00:00:00" diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 56d793818cb..c42ef9df5ac 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -12,6 +12,12 @@ "exceptions": { "invalid_date": { "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." } }, "entity": { @@ -53,6 +59,10 @@ "name": "Get gas prices", "description": "Request gas prices from easyEnergy.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "incl_vat": { "name": "VAT Included", "description": "Include or exclude VAT in the prices, default is true." @@ -71,6 +81,10 @@ "name": "Get energy usage prices", "description": "Request usage energy prices from easyEnergy.", "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, "incl_vat": { "name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]", "description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]" @@ -89,6 +103,10 @@ "name": "Get energy return prices", "description": "Request return energy prices from easyEnergy.", "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, "start": { "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py index d47b86e93a3..603768237f1 100644 --- a/tests/components/easyenergy/test_services.py +++ b/tests/components/easyenergy/test_services.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.components.easyenergy.services import ( + ATTR_CONFIG_ENTRY, ENERGY_RETURN_SERVICE_NAME, ENERGY_USAGE_SERVICE_NAME, GAS_SERVICE_NAME, @@ -13,6 +14,8 @@ from homeassistant.components.easyenergy.services import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("init_integration") async def test_has_services( @@ -38,6 +41,7 @@ async def test_has_services( @pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) async def test_service( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, service: str, incl_vat: dict[str, bool], @@ -45,8 +49,9 @@ async def test_service( end: dict[str, str], ) -> None: """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} - data = incl_vat | start | end + data = entry | incl_vat | start | end assert snapshot == await hass.services.async_call( DOMAIN, @@ -57,6 +62,17 @@ async def test_service( ) +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "service", @@ -67,29 +83,58 @@ async def test_service( ], ) @pytest.mark.parametrize( - ("service_data", "error", "error_message"), + ("config_entry_data", "service_data", "error", "error_message"), [ - ({}, vol.er.Error, "required key not provided .+"), + ({}, {}, vol.er.Error, "required key not provided .+"), ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, {"incl_vat": "incorrect vat"}, vol.er.Error, "expected bool for dictionary value .+", ), ( - {"incl_vat": True, "start": "incorrect date"}, + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ( - {"incl_vat": True, "end": "incorrect date"}, + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ], + indirect=["config_entry_data"], ) async def test_service_validation( hass: HomeAssistant, service: str, + config_entry_data: dict[str, str], service_data: dict[str, str | bool], error: type[Exception], error_message: str, @@ -100,7 +145,7 @@ async def test_service_validation( await hass.services.async_call( DOMAIN, service, - service_data, + config_entry_data | service_data, blocking=True, return_response=True, ) From c51ac7171a7e08c153358259119c8c813f9d97d2 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Wed, 27 Dec 2023 13:07:44 +0100 Subject: [PATCH 780/927] Add translatable title to Persistent Notification (#104661) --- homeassistant/components/persistent_notification/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json index 5f256233149..ca89a4d33cd 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -1,4 +1,5 @@ { + "title": "Persistent Notification", "services": { "create": { "name": "Create", From 2497798b5d54910faf3c95be4b1b3e6a46a943ed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Dec 2023 04:14:59 -0800 Subject: [PATCH 781/927] Allow clearing To-do list item extended fields (#106208) --- homeassistant/components/caldav/todo.py | 40 +++-- homeassistant/components/google_tasks/todo.py | 14 +- homeassistant/components/local_todo/todo.py | 32 ++-- .../components/shopping_list/todo.py | 11 +- homeassistant/components/todo/__init__.py | 32 ++-- homeassistant/components/todoist/todo.py | 35 ++-- tests/components/caldav/test_todo.py | 163 +++++++++++++++-- .../google_tasks/snapshots/test_todo.ambr | 43 ++++- tests/components/google_tasks/test_todo.py | 15 +- tests/components/local_todo/test_todo.py | 169 ++++++++++++++++-- tests/components/todo/test_init.py | 130 ++++++++++++-- tests/components/todoist/test_todo.py | 91 ++++++++-- 12 files changed, 627 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index b7089c3da65..90380805c31 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -90,20 +90,6 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: ) -def _to_ics_fields(item: TodoItem) -> dict[str, Any]: - """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["summary"] = summary - if status := item.status: - item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") - if due := item.due: - item_data["due"] = due - if description := item.description: - item_data["description"] = description - return item_data - - class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -140,9 +126,18 @@ class WebDavTodoListEntity(TodoListEntity): async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + item_data["due"] = due + if description := item.description: + item_data["description"] = description try: await self.hass.async_add_executor_job( - partial(self._calendar.save_todo, **_to_ics_fields(item)), + partial(self._calendar.save_todo, **item_data), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -159,10 +154,17 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - updated_fields = _to_ics_fields(item) - if "due" in updated_fields: - todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] - vtodo.update(**updated_fields) + vtodo["SUMMARY"] = item.summary or "" + if status := item.status: + vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + todo.set_due(due) # type: ignore[attr-defined] + else: + vtodo.pop("DUE", None) + if description := item.description: + vtodo["DESCRIPTION"] = description + else: + vtodo.pop("DESCRIPTION", None) try: await self.hass.async_add_executor_job( partial( diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index cf3f84e9a0d..e83b0d39a30 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -29,18 +29,20 @@ TODO_STATUS_MAP = { TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} -def _convert_todo_item(item: TodoItem) -> dict[str, str]: +def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" - result: dict[str, str] = {} - if item.summary is not None: - result["title"] = item.summary + result: dict[str, str | None] = {} + result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + else: + result["status"] = TodoItemStatus.NEEDS_ACTION if (due := item.due) is not None: # due API field is a timestamp string, but with only date resolution result["due"] = dt_util.start_of_local_day(due).isoformat() - if (description := item.description) is not None: - result["notes"] = description + else: + result["due"] = None + result["notes"] = item.description return result diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index c5cf25a8c2e..99fb6dcebfa 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,13 +1,9 @@ """A Local To-do todo platform.""" -from collections.abc import Iterable -import dataclasses import logging -from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus @@ -59,26 +55,18 @@ async def async_setup_entry( async_add_entities([entity], True) -def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: - """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" - result: dict[str, str] = {} - for name, value in obj: - if value is None: - continue - if name == "status": - result[name] = ICS_TODO_STATUS_MAP_INV[value] - else: - result[name] = value - return result - - def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" - try: - return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except CalendarParseError as err: - _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) - raise HomeAssistantError("Error parsing todo input fields") from err + todo = Todo() + if item.uid: + todo.uid = item.uid + if item.summary: + todo.summary = item.summary + if item.status: + todo.status = ICS_TODO_STATUS_MAP_INV[item.status] + todo.due = item.due + todo.description = item.description + return todo class LocalTodoListEntity(TodoListEntity): diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index d89f376d662..2d959858067 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -1,6 +1,6 @@ """A shopping list todo platform.""" -from typing import Any, cast +from typing import cast from homeassistant.components.todo import ( TodoItem, @@ -55,11 +55,10 @@ class ShoppingTodoListEntity(TodoListEntity): async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" - data: dict[str, Any] = {} - if item.summary: - data["name"] = item.summary - if item.status: - data["complete"] = item.status == TodoItemStatus.COMPLETED + data = { + "name": item.summary, + "complete": item.status == TodoItemStatus.COMPLETED, + } try: await self._data.async_update(item.uid, data) except NoMatchingShoppingListItem as err: diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index d94233a20b9..0f39d38eb46 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -74,19 +74,19 @@ class TodoItemFieldDescription: TODO_ITEM_FIELDS = [ TodoItemFieldDescription( service_field=ATTR_DUE_DATE, - validation=cv.date, + validation=vol.Any(cv.date, None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DUE_DATETIME, - validation=vol.All(cv.datetime, dt_util.as_local), + validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DESCRIPTION, - validation=cv.string, + validation=vol.Any(cv.string, None), todo_item_field=ATTR_DESCRIPTION, required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, ), @@ -485,18 +485,22 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> _validate_supported_features(entity.supported_features, call.data) - await entity.async_update_todo_item( - item=TodoItem( - uid=found.uid, - summary=call.data.get("rename"), - status=call.data.get("status"), - **{ - desc.todo_item_field: call.data[desc.service_field] - for desc in TODO_ITEM_FIELDS - if desc.service_field in call.data - }, - ) + # Perform a partial update on the existing entity based on the fields + # present in the update. This allows explicitly clearing any of the + # extended fields present and set to None. + updated_data = dataclasses.asdict(found) + if summary := call.data.get("rename"): + updated_data["summary"] = summary + if status := call.data.get("status"): + updated_data["status"] = status + updated_data.update( + { + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + } ) + await entity.async_update_todo_item(item=TodoItem(**updated_data)) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 6231a6878ae..5067e98642e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -34,19 +34,20 @@ async def async_setup_entry( def _task_api_data(item: TodoItem) -> dict[str, Any]: """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["content"] = summary + item_data: dict[str, Any] = { + "content": item.summary, + # Description needs to be empty string to be cleared + "description": item.description or "", + } if due := item.due: if isinstance(due, datetime.datetime): - item_data["due"] = { - "date": due.date().isoformat(), - "datetime": due.isoformat(), - } + item_data["due_datetime"] = due.isoformat() else: - item_data["due"] = {"date": due.isoformat()} - if description := item.description: - item_data["description"] = description + item_data["due_date"] = due.isoformat() + else: + # Special flag "no date" clears the due date/datetime. + # See https://developer.todoist.com/rest/v2/#update-a-task for more. + item_data["due_string"] = "no date" return item_data @@ -128,10 +129,16 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if update_data := _task_api_data(item): await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: - if item.status == TodoItemStatus.COMPLETED: - await self.coordinator.api.close_task(task_id=uid) - else: - await self.coordinator.api.reopen_task(task_id=uid) + # Only update status if changed + for existing_item in self._attr_todo_items or (): + if existing_item.uid != item.uid: + continue + + if item.status != existing_item.status: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) await self.coordinator.async_refresh() async def async_delete_todo_items(self, uids: list[str]) -> None: diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index a90529297be..6056cac5fa9 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -69,6 +69,19 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +TODO_ALL_FIELDS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +DESCRIPTION:Any kind will do +STATUS:NEEDS-ACTION +DUE:20171126 +END:VTODO +END:VCALENDAR""" + @pytest.fixture def platforms() -> list[Platform]: @@ -132,6 +145,18 @@ async def mock_add_to_hass( config_entry.add_to_hass(hass) +IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"] + + +def compact_ics(ics: str) -> list[str]: + """Pull out parts of the rfc5545 content useful for assertions in tests.""" + return [ + line + for line in ics.split("\n") + if line and not any(filter(line.startswith, IGNORE_COMPONENTS)) + ] + + @pytest.mark.parametrize( ("todos", "expected_state"), [ @@ -292,45 +317,148 @@ async def test_add_item_failure( [ ( {"rename": "Swiss Cheese"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "needs_action"}, - ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - RESULT_ITEM, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "completed"}, - ["SUMMARY:Cheese", "STATUS:COMPLETED"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:COMPLETED", + "SUMMARY:Cheese", + ], "0", - {**RESULT_ITEM, "status": "completed"}, + { + "uid": "2", + "summary": "Cheese", + "status": "completed", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"rename": "Swiss Cheese", "status": "needs_action"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20231118", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18", + }, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], + [ + "DESCRIPTION:Any kind will do", + "DUE;TZID=America/Regina:20231118T083000", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18T08:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + [ + "DESCRIPTION:Any kind will do", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + }, ), ( {"description": "Make sure to get Swiss"}, - ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + [ + "DESCRIPTION:Make sure to get Swiss", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + "description": "Make sure to get Swiss", + }, + ), + ( + {"description": None}, + ["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + }, ), ], ids=[ @@ -340,7 +468,9 @@ async def test_add_item_failure( "rename_status", "due_date", "due_datetime", + "clear_due_date", "description", + "clear_description", ], ) async def test_update_item( @@ -355,7 +485,7 @@ async def test_update_item( ) -> None: """Test updating an item on the list.""" - item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item = Todo(dav_client, None, TODO_ALL_FIELDS, calendar, "2") calendar.search = MagicMock(return_value=[item]) await config_entry.async_setup(hass) @@ -381,8 +511,7 @@ async def test_update_item( assert dav_client.put.call_args ics = dav_client.put.call_args.args[1] - for expected in expected_ics: - assert expected in ics + assert compact_ics(ics) == expected_ics state = hass.states.get(TEST_ENTITY) assert state diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index e30739551f3..af8dec6a182 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -6,7 +6,7 @@ ) # --- # name: test_create_todo_list_item[description].1 - '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": "6-pack"}' # --- # name: test_create_todo_list_item[due] tuple( @@ -15,7 +15,7 @@ ) # --- # name: test_create_todo_list_item[due].1 - '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' # --- # name: test_create_todo_list_item[summary] tuple( @@ -24,7 +24,7 @@ ) # --- # name: test_create_todo_list_item[summary].1 - '{"title": "Soda", "status": "needsAction"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_delete_todo_list_item[_handler] tuple( @@ -106,6 +106,24 @@ }), ]) # --- +# name: test_partial_update[clear_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- +# name: test_partial_update[clear_due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_due_date].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- # name: test_partial_update[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -113,7 +131,7 @@ ) # --- # name: test_partial_update[description].1 - '{"notes": "6-pack"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": "At least one gallon"}' # --- # name: test_partial_update[due_date] tuple( @@ -122,7 +140,16 @@ ) # --- # name: test_partial_update[due_date].1 - '{"due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' +# --- +# name: test_partial_update[empty_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[empty_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": ""}' # --- # name: test_partial_update[rename] tuple( @@ -131,7 +158,7 @@ ) # --- # name: test_partial_update[rename].1 - '{"title": "Soda"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_partial_update_status[api_responses0] tuple( @@ -140,7 +167,7 @@ ) # --- # name: test_partial_update_status[api_responses0].1 - '{"status": "needsAction"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_update_todo_list_item[api_responses0] tuple( @@ -149,5 +176,5 @@ ) # --- # name: test_update_todo_list_item[api_responses0].1 - '{"title": "Soda", "status": "completed"}' + '{"title": "Soda", "status": "completed", "due": null, "notes": null}' # --- diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index bf9a3f03df0..ee1b1e4cfd4 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -48,6 +48,7 @@ LIST_TASKS_RESPONSE_WATER = { "id": "some-task-id", "title": "Water", "status": "needsAction", + "description": "Any size is ok", "position": "00000000000000000001", }, ], @@ -516,9 +517,19 @@ async def test_update_todo_list_item_error( [ (UPDATE_API_RESPONSES, {"rename": "Soda"}), (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), - (UPDATE_API_RESPONSES, {"description": "6-pack"}), + (UPDATE_API_RESPONSES, {"due_date": None}), + (UPDATE_API_RESPONSES, {"description": "At least one gallon"}), + (UPDATE_API_RESPONSES, {"description": ""}), + (UPDATE_API_RESPONSES, {"description": None}), ], - ids=("rename", "due_date", "description"), + ids=( + "rename", + "due_date", + "clear_due_date", + "description", + "empty_description", + "clear_description", + ), ) async def test_partial_update( hass: HomeAssistant, diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 67d0703ca7c..22d8abade50 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -65,16 +65,27 @@ def set_time_zone(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Regina") +EXPECTED_ADD_ITEM = { + "status": "needs_action", + "summary": "replace batteries", +} + + @pytest.mark.parametrize( ("item_data", "expected_item_data"), [ - ({}, {}), - ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ({}, EXPECTED_ADD_ITEM), + ({"due_date": "2023-11-17"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17"}), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_ADD_ITEM, "due": "2023-11-17T05:30:00-06:00"}, ), - ({"description": "Additional detail"}, {"description": "Additional detail"}), + ( + {"description": "Additional detail"}, + {**EXPECTED_ADD_ITEM, "description": "Additional detail"}, + ), + ({"description": ""}, {**EXPECTED_ADD_ITEM, "description": ""}), + ({"description": None}, EXPECTED_ADD_ITEM), ], ) async def test_add_item( @@ -101,11 +112,10 @@ async def test_add_item( items = await ws_get_items() assert len(items) == 1 - assert items[0]["summary"] == "replace batteries" - assert items[0]["status"] == "needs_action" - for k, v in expected_item_data.items(): - assert items[0][k] == v - assert "uid" in items[0] + item_data = items[0] + assert "uid" in item_data + del item_data["uid"] + assert item_data == expected_item_data state = hass.states.get(TEST_ENTITY) assert state @@ -207,19 +217,29 @@ async def test_bulk_remove( assert state.state == "0" +EXPECTED_UPDATE_ITEM = { + "status": "needs_action", + "summary": "soda", +} + + @pytest.mark.parametrize( ("item_data", "expected_item_data", "expected_state"), [ - ({"status": "completed"}, {"status": "completed"}, "0"), - ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), + ({"status": "completed"}, {**EXPECTED_UPDATE_ITEM, "status": "completed"}, "0"), + ( + {"due_date": "2023-11-17"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17"}, + "1", + ), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17T05:30:00-06:00"}, "1", ), ( {"description": "Additional detail"}, - {"description": "Additional detail"}, + {**EXPECTED_UPDATE_ITEM, "description": "Additional detail"}, "1", ), ], @@ -246,6 +266,7 @@ async def test_update_item( # Fetch item items = await ws_get_items() assert len(items) == 1 + item = items[0] assert item["summary"] == "soda" assert item["status"] == "needs_action" @@ -254,7 +275,7 @@ async def test_update_item( assert state assert state.state == "1" - # Mark item completed + # Update item await hass.services.async_call( TODO_DOMAIN, "update_item", @@ -268,14 +289,130 @@ async def test_update_item( assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - for k, v in expected_item_data.items(): - assert items[0][k] == v + assert "uid" in item + del item["uid"] + assert item == expected_item_data state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ( + {"status": "completed"}, + { + "summary": "soda", + "status": "completed", + "description": "Additional detail", + "due": "2024-01-01", + }, + ), + ( + {"due_date": "2024-01-02"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-02", + }, + ), + ( + {"due_date": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"due_datetime": "2024-01-01 10:30:00"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-01T10:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"description": "updated description"}, + { + "summary": "soda", + "status": "needs_action", + "due": "2024-01-01", + "description": "updated description", + }, + ), + ( + {"description": None}, + {"summary": "soda", "status": "needs_action", "due": "2024-01-01"}, + ), + ], + ids=[ + "status", + "due_date", + "clear_due_date", + "due_datetime", + "clear_due_datetime", + "description", + "clear_description", + ], +) +async def test_update_existing_field( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda", "description": "Additional detail", "due_date": "2024-01-01"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + # Perform update + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], **item_data}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is updated + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert "uid" in item + del item["uid"] + assert item == expected_item_data + + async def test_rename( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0edca7a7ef6..e1440b292ee 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -146,15 +146,19 @@ async def create_mock_platform( return config_entry +@pytest.fixture(name="test_entity_items") +def mock_test_entity_items() -> list[TodoItem]: + """Fixture that creates the items returned by the test entity.""" + return [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + + @pytest.fixture(name="test_entity") -def mock_test_entity() -> TodoListEntity: +def mock_test_entity(test_entity_items: list[TodoItem]) -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = MockTodoListEntity( - [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), - ] - ) + entity1 = MockTodoListEntity(test_entity_items) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -504,7 +508,7 @@ async def test_update_todo_item_service_by_id_status_only( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary is None + assert item.summary == "Item #1" assert item.status == TodoItemStatus.COMPLETED @@ -530,7 +534,7 @@ async def test_update_todo_item_service_by_id_rename( assert item assert item.uid == "1" assert item.summary == "Updated item" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_raises( @@ -607,7 +611,7 @@ async def test_update_todo_item_service_by_summary_only_status( assert item assert item.uid == "1" assert item.summary == "Something else" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_by_summary_not_found( @@ -693,20 +697,32 @@ async def test_update_todo_item_field_unsupported( ( TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, {"due_date": "2023-11-13"}, - TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), ), ), ( TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, {"description": "Submit revised draft"}, - TodoItem(uid="1", description="Submit revised draft"), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), ), ), ) @@ -736,6 +752,96 @@ async def test_update_todo_item_extended_fields( assert item == expected_update +@pytest.mark.parametrize( + ("test_entity_items", "update_data", "expected_update"), + ( + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": "Submit revised draft"}, + TodoItem(uid="1", summary="Summary", description="Submit revised draft"), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": ""}, + TodoItem(uid="1", summary="Summary", description=""), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": datetime.date(2024, 1, 2)}, + TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)}, + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime( + 2024, 1, 1, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/Regina") + ), + ), + ), + ( + [ + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime(2024, 1, 1, 10, 0, 0), + ) + ], + {"due_datetime": None}, + TodoItem(uid="1", summary="Summary"), + ), + ), + ids=[ + "overwrite_description", + "overwrite_empty_description", + "clear_description", + "overwrite_due_date", + "clear_due_date", + "overwrite_due_date_with_time", + "clear_due_date_time", + ], +) +async def test_update_todo_item_extended_fields_overwrite_existing_values( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + ) + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_update + + async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 1e94b52149c..5aa1e2af9de 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -80,7 +80,7 @@ async def test_todo_item_state( [], {}, [make_api_task(id="task-id-1", content="Soda", is_completed=False)], - {"content": "Soda"}, + {"content": "Soda", "due_string": "no date", "description": ""}, {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, ), ( @@ -94,7 +94,7 @@ async def test_todo_item_state( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"due": {"date": "2023-11-18"}}, + {"description": "", "due_date": "2023-11-18"}, { "uid": "task-id-1", "summary": "Soda", @@ -119,7 +119,8 @@ async def test_todo_item_state( ) ], { - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "description": "", + "due_datetime": "2023-11-18T06:30:00-06:00", }, { "uid": "task-id-1", @@ -139,7 +140,7 @@ async def test_todo_item_state( is_completed=False, ) ], - {"description": "6-pack"}, + {"description": "6-pack", "due_string": "no date"}, { "uid": "task-id-1", "summary": "Soda", @@ -264,11 +265,35 @@ async def test_update_todo_item_status( ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), [ ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="desc", + ) + ], {"rename": "Milk"}, - [make_api_task(id="task-id-1", content="Milk", is_completed=False)], - {"task_id": "task-id-1", "content": "Milk"}, - {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + [ + make_api_task( + id="task-id-1", + content="Milk", + is_completed=False, + description="desc", + ) + ], + { + "task_id": "task-id-1", + "content": "Milk", + "description": "desc", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Milk", + "status": "needs_action", + "description": "desc", + }, ), ( [make_api_task(id="task-id-1", content="Soda", is_completed=False)], @@ -281,7 +306,12 @@ async def test_update_todo_item_status( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "task_id": "task-id-1", + "content": "Soda", + "due_date": "2023-11-18", + "description": "", + }, { "uid": "task-id-1", "summary": "Soda", @@ -307,7 +337,9 @@ async def test_update_todo_item_status( ], { "task_id": "task-id-1", - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "content": "Soda", + "due_datetime": "2023-11-18T06:30:00-06:00", + "description": "", }, { "uid": "task-id-1", @@ -327,7 +359,12 @@ async def test_update_todo_item_status( is_completed=False, ) ], - {"task_id": "task-id-1", "description": "6-pack"}, + { + "task_id": "task-id-1", + "content": "Soda", + "description": "6-pack", + "due_string": "no date", + }, { "uid": "task-id-1", "summary": "Soda", @@ -335,8 +372,38 @@ async def test_update_todo_item_status( "description": "6-pack", }, ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": None}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="", + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + }, + ), ], - ids=["rename", "due_date", "due_datetime", "description"], + ids=["rename", "due_date", "due_datetime", "description", "clear_description"], ) async def test_update_todo_items( hass: HomeAssistant, From 7746a9454357bd6553b70faa475978ca5325777c Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 27 Dec 2023 07:20:09 -0500 Subject: [PATCH 782/927] Fix Hydrawise watering time duration unit (#105919) Co-authored-by: Martin Hjelmare --- homeassistant/components/hydrawise/const.py | 2 +- homeassistant/components/hydrawise/switch.py | 8 ++++---- tests/components/hydrawise/test_switch.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index dc53d847b1f..724b6ee6203 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -9,7 +9,7 @@ ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] CONF_WATERING_TIME = "watering_minutes" DOMAIN = "hydrawise" -DEFAULT_WATERING_TIME = 15 +DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 5dd79d4a13e..5a3a3a62895 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -52,9 +52,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( cv.ensure_list, [vol.In(SWITCH_KEYS)] ), - vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): vol.All( - vol.In(ALLOWED_WATERING_TIME) - ), + vol.Optional( + CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME.total_seconds() // 60 + ): vol.All(vol.In(ALLOWED_WATERING_TIME)), } ) @@ -96,7 +96,7 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """Turn the device on.""" if self.entity_description.key == "manual_watering": await self.coordinator.api.start_zone( - self.zone, custom_run_duration=DEFAULT_WATERING_TIME + self.zone, custom_run_duration=DEFAULT_WATERING_TIME.total_seconds() ) elif self.entity_description.key == "auto_watering": await self.coordinator.api.resume_zone(self.zone) diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 30a58735122..f044d3467cd 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -51,7 +51,7 @@ async def test_manual_watering_services( blocking=True, ) mock_pydrawise.start_zone.assert_called_once_with( - zones[0], custom_run_duration=DEFAULT_WATERING_TIME + zones[0], custom_run_duration=DEFAULT_WATERING_TIME.total_seconds() ) state = hass.states.get("switch.zone_one_manual_watering") assert state is not None From 0f3e6b0dece9e149fa6060730c536f9aefaa3a74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 13:22:12 +0100 Subject: [PATCH 783/927] Revert "Add preselect_remember_me to `/auth/providers`" (#106472) Revert "Add preselect_remember_me to `/auth/providers` (#106462)" This reverts commit c19688e2d20532860711fbcf9530980761752c4b. --- homeassistant/components/auth/login_flow.py | 10 +- homeassistant/components/person/__init__.py | 21 ++++ tests/components/auth/test_login_flow.py | 106 +++++++++++++------- tests/components/person/test_init.py | 30 +++++- 4 files changed, 118 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index cc6cb5fc47a..9b96e57dbd3 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,7 +91,6 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_cloud_connection -from homeassistant.util.network import is_local from . import indieauth @@ -186,14 +185,7 @@ class AuthProvidersView(HomeAssistantView): } ) - preselect_remember_me = not cloud_connection and is_local(remote_address) - - return self.json( - { - "providers": providers, - "preselect_remember_me": preselect_remember_me, - } - ) + return self.json(providers) def _prepare_result_json( diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49b719a5490..c796cb8d843 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,9 +1,11 @@ """Support for tracking people.""" from __future__ import annotations +from http import HTTPStatus import logging from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -13,6 +15,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) +from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -385,6 +388,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) + hass.http.register_view(ListPersonsView) + return True @@ -569,3 +574,19 @@ def _get_latest(prev: State | None, curr: State): if prev is None or curr.last_updated > prev.last_updated: return curr return prev + + +class ListPersonsView(HomeAssistantView): + """List all persons if request is made from a local network.""" + + requires_auth = False + url = "/api/person/list" + name = "api:person:list" + + async def get(self, request: web.Request) -> web.Response: + """Return a list of persons if request comes from a local IP.""" + return self.json_message( + message="Not local", + status_code=HTTPStatus.BAD_REQUEST, + message_code="not_local", + ) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index c8b0261b79c..27652ca2be4 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from . import BASE_CONFIG, async_setup_auth @@ -25,30 +26,22 @@ _TRUSTED_NETWORKS_CONFIG = { @pytest.mark.parametrize( - ("ip", "preselect_remember_me"), - [ - ("192.168.1.10", True), - ("::ffff:192.168.0.10", True), - ("1.2.3.4", False), - ("2001:db8::1", False), - ], -) -@pytest.mark.parametrize( - ("provider_configs", "expected"), + ("provider_configs", "ip", "expected"), [ ( BASE_CONFIG, + None, [{"name": "Example", "type": "insecure_example", "id": None}], ), ( - [{"type": "homeassistant"}], - [ - { - "name": "Home Assistant Local", - "type": "homeassistant", - "id": None, - } - ], + [_TRUSTED_NETWORKS_CONFIG], + None, + [], + ), + ( + [_TRUSTED_NETWORKS_CONFIG], + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], ), ], ) @@ -56,9 +49,8 @@ async def test_fetch_auth_providers( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, provider_configs: list[dict[str, Any]], + ip: str | None, expected: list[dict[str, Any]], - ip: str, - preselect_remember_me: bool, ) -> None: """Test fetching auth providers.""" client = await async_setup_auth( @@ -66,37 +58,73 @@ async def test_fetch_auth_providers( ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == { - "providers": expected, - "preselect_remember_me": preselect_remember_me, + assert await resp.json() == expected + + +async def _test_fetch_auth_providers_home_assistant( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, +) -> None: + """Test fetching auth providers for homeassistant auth provider.""" + client = await async_setup_auth( + hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip + ) + + expected = { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, } + resp = await client.get("/auth/providers") + assert resp.status == HTTPStatus.OK + assert await resp.json() == [expected] + @pytest.mark.parametrize( - ("ip", "expected"), + "ip", [ - ( - "192.168.0.1", - [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], - ), - ("::ffff:192.168.0.10", []), - ("1.2.3.4", []), - ("2001:db8::1", []), + "192.168.0.10", + "::ffff:192.168.0.10", + "1.2.3.4", + "2001:db8::1", ], ) -async def test_fetch_auth_providers_trusted_network( +async def test_fetch_auth_providers_home_assistant_person_not_loaded( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, - expected: list[dict[str, Any]], ip: str, ) -> None: - """Test fetching auth providers.""" - client = await async_setup_auth( - hass, aiohttp_client, [_TRUSTED_NETWORKS_CONFIG], custom_ip=ip + """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" + await _test_fetch_auth_providers_home_assistant(hass, aiohttp_client, ip) + + +@pytest.mark.parametrize( + ("ip", "is_local"), + [ + ("192.168.0.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +async def test_fetch_auth_providers_home_assistant_person_loaded( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, + is_local: bool, +) -> None: + """Test fetching auth providers for homeassistant auth provider, where person integration is loaded.""" + domain = "person" + config = {domain: {"id": "1234", "name": "test person"}} + assert await async_setup_component(hass, domain, config) + + await _test_fetch_auth_providers_home_assistant( + hass, + aiohttp_client, + ip, ) - resp = await client.get("/auth/providers") - assert resp.status == HTTPStatus.OK - assert (await resp.json())["providers"] == expected async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 71491ee3caf..1866f682b55 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,4 +1,5 @@ """The tests for the person component.""" +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -29,7 +30,7 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -847,3 +848,30 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] + + +async def test_list_persons( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_admin_user: MockUser, +) -> None: + """Test listing persons from a not local ip address.""" + + user_id = hass_admin_user.id + admin = {"id": "1234", "name": "Admin", "user_id": user_id, "picture": "/bla"} + config = { + DOMAIN: [ + admin, + {"id": "5678", "name": "Only a person"}, + ] + } + assert await async_setup_component(hass, DOMAIN, config) + + await async_setup_component(hass, "api", {}) + client = await hass_client_no_auth() + + resp = await client.get("/api/person/list") + + assert resp.status == HTTPStatus.BAD_REQUEST + result = await resp.json() + assert result == {"code": "not_local", "message": "Not local"} From 391189459963c71cd7ac884f1018e06d5d863473 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Wed, 27 Dec 2023 12:37:13 +0000 Subject: [PATCH 784/927] Fix roon volume truncation bug (#105696) --- homeassistant/components/roon/manifest.json | 2 +- homeassistant/components/roon/media_player.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 2598d9e8de1..0dcb5b87581 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.5"] + "requirements": ["roonapi==0.1.6"] } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index dda323c2c2a..afbf0e6b4a7 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -207,13 +207,14 @@ class RoonDevice(MediaPlayerEntity): try: volume_max = volume_data["max"] volume_min = volume_data["min"] + raw_level = convert(volume_data["value"], float, 0) volume_range = volume_max - volume_min volume_percentage_factor = volume_range / 100 level = (raw_level - volume_min) / volume_percentage_factor - volume["level"] = convert(level, int, 0) / 100 + volume["level"] = round(level) / 100 except KeyError: pass diff --git a/requirements_all.txt b/requirements_all.txt index 6f9b42daa70..396f562629d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2403,7 +2403,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rova rova==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edd500c2bdd..bdf461bda72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1810,7 +1810,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From 2d5176d1f6c84f8551aff68c63c878c16b00b6d9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Dec 2023 13:39:01 +0100 Subject: [PATCH 785/927] Use entity descriptions in Netgear LTE (#106414) --- .../components/netgear_lte/__init__.py | 35 ++++- .../components/netgear_lte/binary_sensor.py | 42 ++++-- .../components/netgear_lte/sensor.py | 131 ++++++++++-------- .../components/netgear_lte/sensor_types.py | 42 ------ 4 files changed, 134 insertions(+), 116 deletions(-) delete mode 100644 homeassistant/components/netgear_lte/sensor_types.py diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index c7dd2140555..00a43282210 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -25,7 +25,6 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from . import sensor_types from .const import ( ATTR_FROM, ATTR_HOST, @@ -45,6 +44,28 @@ SCAN_INTERVAL = timedelta(seconds=10) EVENT_SMS = "netgear_lte_sms" +ALL_SENSORS = [ + "sms", + "sms_total", + "usage", + "radio_quality", + "rx_level", + "tx_level", + "upstream", + "connection_text", + "connection_type", + "current_ps_service_type", + "register_network_display", + "current_band", + "cell_id", +] + +ALL_BINARY_SENSORS = [ + "roaming", + "wire_connected", + "mobile_connected", +] + NOTIFY_SCHEMA = vol.Schema( { @@ -55,17 +76,17 @@ NOTIFY_SCHEMA = vol.Schema( SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["usage"]): vol.All( + cv.ensure_list, [vol.In(ALL_SENSORS)] + ) } ) BINARY_SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_BINARY_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_BINARY_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["mobile_connected"]): vol.All( + cv.ensure_list, [vol.In(ALL_BINARY_SENSORS)] + ) } ) diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index b8441d8fb7c..810e3733fbe 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -1,14 +1,32 @@ """Support for Netgear LTE binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ModemData from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import ALL_BINARY_SENSORS, BINARY_SENSOR_CLASSES + +BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="roaming", + ), + BinarySensorEntityDescription( + key="wire_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="mobile_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) async def async_setup_entry( @@ -18,19 +36,23 @@ async def async_setup_entry( modem_data = hass.data[DOMAIN].get_modem_data(entry.data) async_add_entities( - LTEBinarySensor(modem_data, sensor) for sensor in ALL_BINARY_SENSORS + NetgearLTEBinarySensor(modem_data, sensor) for sensor in BINARY_SENSORS ) -class LTEBinarySensor(LTEEntity, BinarySensorEntity): +class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" + def __init__( + self, + modem_data: ModemData, + entity_description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Netgear LTE binary sensor entity.""" + super().__init__(modem_data, entity_description.key) + self.entity_description = entity_description + @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self.modem_data.data, self.sensor_type) - - @property - def device_class(self): - """Return the class of binary sensor.""" - return BINARY_SENSOR_CLASSES[self.sensor_type] + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 5632999ae96..b91bb9b561a 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -1,19 +1,72 @@ """Support for Netgear LTE sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfInformation, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from . import ModemData from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import ( - ALL_SENSORS, - SENSOR_SMS, - SENSOR_SMS_TOTAL, - SENSOR_UNITS, - SENSOR_USAGE, + + +@dataclass(frozen=True, kw_only=True) +class NetgearLTESensorEntityDescription(SensorEntityDescription): + """Class describing Netgear LTE entities.""" + + value_fn: Callable[[ModemData], StateType] | None = None + + +SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( + NetgearLTESensorEntityDescription( + key="sms", + native_unit_of_measurement="unread", + value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), + ), + NetgearLTESensorEntityDescription( + key="sms_total", + native_unit_of_measurement="messages", + value_fn=lambda modem_data: len(modem_data.data.sms), + ), + NetgearLTESensorEntityDescription( + key="usage", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.MEBIBYTES, + value_fn=lambda modem_data: round(modem_data.data.usage / 1024**2, 1), + ), + NetgearLTESensorEntityDescription( + key="radio_quality", + native_unit_of_measurement=PERCENTAGE, + ), + NetgearLTESensorEntityDescription( + key="rx_level", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription( + key="tx_level", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription(key="upstream"), + NetgearLTESensorEntityDescription(key="connection_text"), + NetgearLTESensorEntityDescription(key="connection_type"), + NetgearLTESensorEntityDescription(key="current_ps_service_type"), + NetgearLTESensorEntityDescription(key="register_network_display"), + NetgearLTESensorEntityDescription(key="current_band"), + NetgearLTESensorEntityDescription(key="cell_id"), ) @@ -23,62 +76,26 @@ async def async_setup_entry( """Set up the Netgear LTE sensor.""" modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - sensors: list[SensorEntity] = [] - for sensor in ALL_SENSORS: - if sensor == SENSOR_SMS: - sensors.append(SMSUnreadSensor(modem_data, sensor)) - elif sensor == SENSOR_SMS_TOTAL: - sensors.append(SMSTotalSensor(modem_data, sensor)) - elif sensor == SENSOR_USAGE: - sensors.append(UsageSensor(modem_data, sensor)) - else: - sensors.append(GenericSensor(modem_data, sensor)) - - async_add_entities(sensors) + async_add_entities(NetgearLTESensor(modem_data, sensor) for sensor in SENSORS) -class LTESensor(LTEEntity, SensorEntity): +class NetgearLTESensor(LTEEntity, SensorEntity): """Base LTE sensor entity.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_UNITS[self.sensor_type] + entity_description: NetgearLTESensorEntityDescription - -class SMSUnreadSensor(LTESensor): - """Unread SMS sensor entity.""" + def __init__( + self, + modem_data: ModemData, + entity_description: NetgearLTESensorEntityDescription, + ) -> None: + """Initialize a Netgear LTE sensor entity.""" + super().__init__(modem_data, entity_description.key) + self.entity_description = entity_description @property - def native_value(self): - """Return the state of the sensor.""" - return sum(1 for x in self.modem_data.data.sms if x.unread) - - -class SMSTotalSensor(LTESensor): - """Total SMS sensor entity.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - return len(self.modem_data.data.sms) - - -class UsageSensor(LTESensor): - """Data usage sensor entity.""" - - _attr_device_class = SensorDeviceClass.DATA_SIZE - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - return round(self.modem_data.data.usage / 1024**2, 1) - - -class GenericSensor(LTESensor): - """Sensor entity with raw state.""" - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(self.modem_data) return getattr(self.modem_data.data, self.sensor_type) diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py deleted file mode 100644 index 01aa267e953..00000000000 --- a/homeassistant/components/netgear_lte/sensor_types.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Define possible sensor types.""" - -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfInformation, -) - -SENSOR_SMS = "sms" -SENSOR_SMS_TOTAL = "sms_total" -SENSOR_USAGE = "usage" - -SENSOR_UNITS = { - SENSOR_SMS: "unread", - SENSOR_SMS_TOTAL: "messages", - SENSOR_USAGE: UnitOfInformation.MEBIBYTES, - "radio_quality": PERCENTAGE, - "rx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "tx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "upstream": None, - "connection_text": None, - "connection_type": None, - "current_ps_service_type": None, - "register_network_display": None, - "current_band": None, - "cell_id": None, -} - -BINARY_SENSOR_MOBILE_CONNECTED = "mobile_connected" - -BINARY_SENSOR_CLASSES = { - "roaming": None, - "wire_connected": BinarySensorDeviceClass.CONNECTIVITY, - BINARY_SENSOR_MOBILE_CONNECTED: BinarySensorDeviceClass.CONNECTIVITY, -} - -ALL_SENSORS = list(SENSOR_UNITS) -DEFAULT_SENSORS = [SENSOR_USAGE] - -ALL_BINARY_SENSORS = list(BINARY_SENSOR_CLASSES) -DEFAULT_BINARY_SENSORS = [BINARY_SENSOR_MOBILE_CONNECTED] From b5012a99640bbbd4400e8359043a1941fa258442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 27 Dec 2023 13:42:24 +0100 Subject: [PATCH 786/927] Adjust the GitHub config flow (#105295) --- .../components/github/config_flow.py | 81 ++++++++++++------- tests/components/github/test_config_flow.py | 41 +++++++++- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 5e223483e2e..c90caf0fc89 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from typing import Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any from aiogithubapi import ( GitHubAPI, @@ -17,7 +18,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, @@ -118,19 +119,27 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle device steps.""" async def _wait_for_login() -> None: - # mypy is not aware that we can't get here without having these set already - assert self._device is not None - assert self._login_device is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + assert self._login_device is not None try: response = await self._device.activation( device_code=self._login_device.device_code ) self._login = response.data + finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + + async def _progress(): + # If the user closes the dialog the flow will no longer exist and it will raise UnknownFlow + with suppress(UnknownFlow): + await self.hass.config_entries.flow.async_configure( + flow_id=self.flow_id + ) + + self.hass.async_create_task(_progress()) if not self._device: self._device = GitHubDeviceAPI( @@ -139,31 +148,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): **{"client_name": SERVER_SOFTWARE}, ) - try: - response = await self._device.register() - self._login_device = response.data - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_abort(reason="could_not_register") + try: + response = await self._device.register() + self._login_device = response.data + except GitHubException as exception: + LOGGER.exception(exception) + return self.async_abort(reason="could_not_register") - if not self.login_task: + if self.login_task is None: self.login_task = self.hass.async_create_task(_wait_for_login()) - return self.async_show_progress( - step_id="device", - progress_action="wait_for_device", - description_placeholders={ - "url": OAUTH_USER_LOGIN, - "code": self._login_device.user_code, - }, - ) - try: - await self.login_task - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_show_progress_done(next_step_id="could_not_register") + if self.login_task.done(): + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="could_not_register") + return self.async_show_progress_done(next_step_id="repositories") - return self.async_show_progress_done(next_step_id="repositories") + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login_device is not None + + return self.async_show_progress( + step_id="device", + progress_action="wait_for_device", + description_placeholders={ + "url": OAUTH_USER_LOGIN, + "code": self._login_device.user_code, + }, + ) async def async_step_repositories( self, @@ -171,8 +182,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle repositories step.""" - # mypy is not aware that we can't get here without having this set already - assert self._login is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login is not None if not user_input: repositories = await get_repositories(self.hass, self._login.access_token) @@ -208,6 +220,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + @callback + def async_remove(self) -> None: + """Handle remove handler callback.""" + if self.login_task and not self.login_task.done(): + # Clean up login task if it's still running + self.login_task.cancel() + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for GitHub.""" diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index a86e1d134aa..8d61eca1ab1 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiogithubapi import GitHubException +import pytest from homeassistant import config_entries from homeassistant.components.github.config_flow import get_repositories @@ -12,7 +13,7 @@ from homeassistant.components.github.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, UnknownFlow from .common import MOCK_ACCESS_TOKEN @@ -126,6 +127,44 @@ async def test_flow_with_activation_failure( assert result["step_id"] == "could_not_register" +async def test_flow_with_remove_while_activating( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test flow with user canceling while activating.""" + aioclient_mock.post( + "https://github.com/login/device/code", + json={ + "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + json={"error": "authorization_pending"}, + headers={"Content-Type": "application/json"}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "device" + assert result["type"] == FlowResultType.SHOW_PROGRESS + + assert hass.config_entries.flow.async_get(result["flow_id"]) + + # Simulate user canceling the flow + hass.config_entries.flow._async_remove_flow_progress(result["flow_id"]) + await hass.async_block_till_done() + + with pytest.raises(UnknownFlow): + hass.config_entries.flow.async_get(result["flow_id"]) + + async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 50b960fb5eff0c2d04ac84723b19f5e84b87f7db Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 27 Dec 2023 13:43:45 +0100 Subject: [PATCH 787/927] Bump PyViCare to 2.32.0 (#106467) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index cbde6242082..97c4b91022d 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.29.0"] + "requirements": ["PyViCare==2.32.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 396f562629d..ecaf2d1e8d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.29.0 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdf461bda72..653b80daf64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.29.0 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 485a02c89db3522b495f78d1bb04c114ea5cd15a Mon Sep 17 00:00:00 2001 From: Daniel Schall Date: Wed, 27 Dec 2023 04:45:49 -0800 Subject: [PATCH 788/927] Fix Generic Camera interval calculation (#105820) --- homeassistant/components/camera/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 0414106a978..9f5ec0a6740 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -12,6 +12,7 @@ from functools import partial import logging import os from random import SystemRandom +import time from typing import TYPE_CHECKING, Any, Final, cast, final from aiohttp import hdrs, web @@ -294,6 +295,7 @@ async def async_get_still_stream( last_image = None while True: + last_fetch = time.monotonic() img_bytes = await image_cb() if not img_bytes: break @@ -307,7 +309,11 @@ async def async_get_still_stream( await write_to_mjpeg_stream(img_bytes) last_image = img_bytes - await asyncio.sleep(interval) + next_fetch = last_fetch + interval + now = time.monotonic() + if next_fetch > now: + sleep_time = next_fetch - now + await asyncio.sleep(sleep_time) return response @@ -411,7 +417,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, preload_stream) @callback - def update_tokens(time: datetime) -> None: + def update_tokens(t: datetime) -> None: """Update tokens of the entities.""" for entity in component.entities: entity.async_update_token() From 0694ff89658c4a64b1fbc4112c0c5956e30d0517 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Dec 2023 13:49:09 +0100 Subject: [PATCH 789/927] Use snapshot assertion in homekit_controller diagnostics tests (#105647) --- .../snapshots/test_diagnostics.ambr | 635 ++++++++++++++++++ .../homekit_controller/test_diagnostics.py | 535 +-------------- 2 files changed, 644 insertions(+), 526 deletions(-) create mode 100644 tests/components/homekit_controller/snapshots/test_diagnostics.ambr diff --git a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d3205b09de3 --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_config_entry + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'devices': list([ + dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + ]), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- +# name: test_device + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'device': dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index a9780c7f80c..c0a9ebbb8d4 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -1,5 +1,7 @@ """Test homekit_controller diagnostics.""" -from unittest.mock import ANY + +from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.core import HomeAssistant @@ -15,7 +17,9 @@ from tests.typing import ClientSessionGenerator async def test_config_entry( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -23,276 +27,14 @@ async def test_config_entry( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "devices": [ - { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Identify" - }, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - } - ], - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) async def test_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -303,263 +45,4 @@ async def test_device( diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "device": { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": {"friendly_name": "Koogeek-LS1-20833F Identify"}, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - }, - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) From 4decc2bbfbefc13e5398864d556494cda1a5593c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 27 Dec 2023 14:17:23 +0100 Subject: [PATCH 790/927] Refactor Tado to use OAuth in the DeviceTracker (#102610) * Refactor to use TadoConnector in the DeviceTracker * Proposing myself as code owner to be notified of issues * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Joost Lekkerkerker * Fixing method names * Current progress, switching machines * Updating DeviceTracker to working prototype * Removing unnecessary callback * Adding dispatcher logic * Minor fine-tuning the intervals * Removing unnecessary debug log * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Martin Hjelmare * Fix sorting * Retrieve devices from the Tado connector data * Asyncio feedback & dispatch generic mobile devices * Updating const * Fine-tuning unloading * Making add_tracked_entites callback * Adding unload over dispatcher_connect * Convert on_demand_update to callback * Removing now unused method * Merging method to on_demand_u * Adding create_issue to address repair * Updating with better translation * Converting to callback * Adding _attr_should_poll * Putting back the on_demand_update * Adding unique_id * Converting to TrackerEntity * Adding import step (review needed!) * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/device_tracker.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Typing and location_name * Changing to _attr_unique_id * Import improvement attempt * Property feedback * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Adding CONF_HOME_ID and task in get_scanner * Updating descriptions * Removing the create_task * Putting back PLATFORM_SCHEMA * Adding device_tracker * Adding get for HomeID * Get it better ;) * Retrieve HomeID from API * Add integration title in dialogs * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Fixing homeID and strings.json * Delete request in strings * Update deprecation date * Adding test cases for import flow * Update tests/components/tado/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/tado/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/tado/test_config_flow.py Co-authored-by: Martin Hjelmare * Removing none * Fixing test cases * Update homeassistant/components/tado/config_flow.py Co-authored-by: Martin Hjelmare * Removing from context manager * Removing code owner * Re-adding code owner * Fix get scanner return value * Fix device tracker interface --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- CODEOWNERS | 4 +- homeassistant/components/tado/__init__.py | 38 +++ homeassistant/components/tado/config_flow.py | 40 +++ homeassistant/components/tado/const.py | 3 + .../components/tado/device_tracker.py | 283 +++++++++++------- homeassistant/components/tado/manifest.json | 2 +- homeassistant/components/tado/strings.json | 14 + tests/components/tado/test_config_flow.py | 117 ++++++++ 8 files changed, 382 insertions(+), 119 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b0dcda5ce27..494f3d42bee 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1299,8 +1299,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/tado/ @michaelarnauts @chiefdragon -/tests/components/tado/ @michaelarnauts @chiefdragon +/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna +/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 7faf918f8da..7f166ccf01a 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -26,9 +26,11 @@ from .const import ( DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, PRESET_AUTO, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, + UPDATE_MOBILE_DEVICE_TRACK, UPDATE_TRACK, ) @@ -38,12 +40,14 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.WATER_HEATER, ] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -85,12 +89,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SCAN_INTERVAL, ) + update_mobile_devices = async_track_time_interval( + hass, + lambda now: tadoconnector.update_mobile_devices(), + SCAN_MOBILE_DEVICE_INTERVAL, + ) + update_listener = entry.add_update_listener(_async_update_listener) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA: tadoconnector, UPDATE_TRACK: update_track, + UPDATE_MOBILE_DEVICE_TRACK: update_mobile_devices, UPDATE_LISTENER: update_listener, } @@ -127,6 +138,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]() hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]() + hass.data[DOMAIN][entry.entry_id][UPDATE_MOBILE_DEVICE_TRACK]() if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) @@ -151,6 +163,7 @@ class TadoConnector: self.devices = None self.data = { "device": {}, + "mobile_device": {}, "weather": {}, "geofence": {}, "zone": {}, @@ -171,6 +184,10 @@ class TadoConnector: self.home_id = tado_home["id"] self.home_name = tado_home["name"] + def get_mobile_devices(self): + """Return the Tado mobile devices.""" + return self.tado.getMobileDevices() + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" @@ -178,6 +195,27 @@ class TadoConnector: self.update_zones() self.update_home() + def update_mobile_devices(self) -> None: + """Update the mobile devices.""" + try: + mobile_devices = self.get_mobile_devices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating mobile devices") + return + + for mobile_device in mobile_devices: + self.data["mobile_device"][mobile_device["id"]] = mobile_device + + _LOGGER.debug( + "Dispatching update to %s mobile devices: %s", + self.home_id, + mobile_devices, + ) + dispatcher_send( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + ) + def update_devices(self): """Update the device data from Tado.""" try: diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a755622ea76..3e183b0a9b5 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_FALLBACK, + CONF_HOME_ID, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, @@ -110,6 +111,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_user() + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.debug("Importing Tado from configuration.yaml") + username = import_config[CONF_USERNAME] + password = import_config[CONF_PASSWORD] + imported_home_id = import_config[CONF_HOME_ID] + + self._async_abort_entries_match( + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: imported_home_id, + } + ) + + try: + validate_result = await validate_input( + self.hass, + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + except exceptions.HomeAssistantError: + return self.async_abort(reason="import_failed") + + home_id = validate_result[UNIQUE_ID] + await self.async_set_unique_id(home_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_config[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: home_id, + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index d6ae50c33c1..c14906c3a89 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -36,8 +36,10 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" +CONF_HOME_ID = "home_id" DATA = "data" UPDATE_TRACK = "update_track" +UPDATE_MOBILE_DEVICE_TRACK = "update_mobile_device_track" # Weather CONDITIONS_MAP = { @@ -177,6 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = { DOMAIN = "tado" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" +SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" UNIQUE_ID = "unique_id" DEFAULT_NAME = "Tado" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 1365c9f23a3..426c7d9ed5d 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -1,33 +1,31 @@ """Support for Tado Smart device trackers.""" from __future__ import annotations -import asyncio -from collections import namedtuple -from datetime import timedelta -from http import HTTPStatus import logging +from typing import Any -import aiohttp import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, + SourceType, + TrackerEntity, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle + +from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED _LOGGER = logging.getLogger(__name__) -CONF_HOME_ID = "home_id" - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -37,113 +35,166 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> TadoDeviceScanner | None: - """Return a Tado scanner.""" - scanner = TadoDeviceScanner(hass, config[DOMAIN]) - return scanner if scanner.success_init else None +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> DeviceScanner | None: + """Configure the Tado device scanner.""" + device_config = config["device_tracker"] + import_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD], + CONF_HOME_ID: device_config.get(CONF_HOME_ID), + }, + ) + + translation_key = "deprecated_yaml_import_device_tracker" + if import_result.get("type") == FlowResultType.ABORT: + translation_key = "import_aborted" + if import_result.get("reason") == "import_failed": + translation_key = "import_failed" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_device_tracker", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) + return None -Device = namedtuple("Device", ["mac", "name"]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tado device scannery entity.""" + _LOGGER.debug("Setting up Tado device scanner entity") + tado = hass.data[DOMAIN][entry.entry_id][DATA] + tracked: set = set() + + @callback + def update_devices() -> None: + """Update the values of the devices.""" + add_tracked_entities(hass, tado, async_add_entities, tracked) + + update_devices() + + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + update_devices, + ) + ) -class TadoDeviceScanner(DeviceScanner): - """Scanner for geofenced devices from Tado.""" - - def __init__(self, hass, config): - """Initialize the scanner.""" - self.hass = hass - self.last_results = [] - - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - # The Tado device tracker can work with or without a home_id - self.home_id = config[CONF_HOME_ID] if CONF_HOME_ID in config else None - - # If there's a home_id, we need a different API URL - if self.home_id is None: - self.tadoapiurl = "https://my.tado.com/api/v2/me" - else: - self.tadoapiurl = "https://my.tado.com/api/v2/homes/{home_id}/mobileDevices" - - # The API URL always needs a username and password - self.tadoapiurl += "?username={username}&password={password}" - - self.websession = None - - self.success_init = asyncio.run_coroutine_threadsafe( - self._async_update_info(), hass.loop - ).result() - - _LOGGER.info("Scanner initialized") - - async def async_scan_devices(self): - """Scan for devices and return a list containing found device ids.""" - await self._async_update_info() - return [device.mac for device in self.last_results] - - async def async_get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - filter_named = [ - result.name for result in self.last_results if result.mac == device - ] - - if filter_named: - return filter_named[0] - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - async def _async_update_info(self): - """Query Tado for device marked as at home. - - Returns boolean if scanning successful. - """ - _LOGGER.debug("Requesting Tado") - - if self.websession is None: - self.websession = async_create_clientsession( - self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - - last_results = [] - - try: - async with asyncio.timeout(10): - # Format the URL here, so we can log the template URL if - # anything goes wrong without exposing username and password. - url = self.tadoapiurl.format( - home_id=self.home_id, username=self.username, password=self.password - ) - - response = await self.websession.get(url) - - if response.status != HTTPStatus.OK: - _LOGGER.warning("Error %d on %s", response.status, self.tadoapiurl) - return False - - tado_json = await response.json() - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Cannot load Tado data") - return False - - # Without a home_id, we fetched an URL where the mobile devices can be - # found under the mobileDevices key. - if "mobileDevices" in tado_json: - tado_json = tado_json["mobileDevices"] - - # Find devices that have geofencing enabled, and are currently at home. - for mobile_device in tado_json: - if mobile_device.get("location") and mobile_device["location"]["atHome"]: - device_id = mobile_device["id"] - device_name = mobile_device["name"] - last_results.append(Device(device_id, device_name)) - - self.last_results = last_results +@callback +def add_tracked_entities( + hass: HomeAssistant, + tado: Any, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: + """Add new tracker entities from Tado.""" + _LOGGER.debug("Fetching Tado devices from API") + new_tracked = [] + for device_key, device in tado.data["mobile_device"].items(): + if device_key in tracked: + continue _LOGGER.debug( - "Tado presence query successful, %d device(s) at home", - len(self.last_results), + "Adding Tado device %s with deviceID %s", device["name"], device_key + ) + new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + tracked.add(device_key) + + async_add_entities(new_tracked) + + +class TadoDeviceTrackerEntity(TrackerEntity): + """A Tado Device Tracker entity.""" + + _attr_should_poll = False + + def __init__( + self, + device_id: str, + device_name: str, + tado: Any, + ) -> None: + """Initialize a Tado Device Tracker entity.""" + super().__init__() + self._attr_unique_id = device_id + self._device_id = device_id + self._device_name = device_name + self._tado = tado + self._active = False + self._latitude = None + self._longitude = None + + @callback + def update_state(self) -> None: + """Update the Tado device.""" + _LOGGER.debug( + "Updating Tado mobile device: %s (ID: %s)", + self._device_name, + self._device_id, + ) + device = self._tado.data["mobile_device"][self._device_id] + + self._active = False + if device.get("location") is not None and device["location"]["atHome"]: + _LOGGER.debug("Tado device %s is at home", device["name"]) + self._active = True + else: + _LOGGER.debug("Tado device %s is not at home", device["name"]) + + @callback + def on_demand_update(self) -> None: + """Update state on demand.""" + self.update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + _LOGGER.debug("Registering Tado device tracker entity") + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + self.on_demand_update, + ) ) - return True + self.update_state() + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device_name + + @property + def location_name(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._active else STATE_NOT_HOME + + @property + def latitude(self) -> None: + """Return latitude value of the device.""" + return None + + @property + def longitude(self) -> None: + """Return longitude value of the device.""" + return None + + @property + def source_type(self) -> SourceType: + """Return the source type.""" + return SourceType.GPS diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 4c6a3eac2c5..467697fc810 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts", "@chiefdragon"], + "codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 9858b7aa51b..157b98e33ea 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -123,5 +123,19 @@ } } } + }, + "issues": { + "deprecated_yaml_import_device_tracker": { + "title": "Tado YAML device tracker configuration imported", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\nRemove the YAML device tracker configuration and restart Home Assistant." + }, + "import_aborted": { + "title": "Import aborted", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado." + }, + "failed_to_import": { + "title": "Failed to import", + "description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration." + } } } diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index c4a39914e53..d83a4b22efc 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -260,3 +260,120 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] == "abort" + + +async def test_import_step(hass: HomeAssistant) -> None: + """Test import step.""" + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "home_id": "1", + } + assert mock_setup_entry.call_count == 1 + + +async def test_import_step_existing_entry(hass: HomeAssistant) -> None: + """Test import step with existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 + + +async def test_import_step_validation_failed(hass: HomeAssistant) -> None: + """Test import step with validation failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed" + + +async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: + """Test import step with unique ID already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + unique_id="unique_id", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 From e04fda3fad13d1a4a09a0e999b4127b7954a075f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Dec 2023 14:46:57 +0100 Subject: [PATCH 791/927] Add config flow to trend (#99761) * Add config flow to trend * Remove device_class from options flow * Add min_samples and import step to config flow * Fix import * Fixing tests and some cleanup * remove unneeded usefixtures * Apply code review suggestions * Re-add YAML support * Re-add reload service * Fix import * Apply code review suggestions * Add test coverage for yaml setup --------- Co-authored-by: G Johansson --- homeassistant/components/trend/__init__.py | 22 +++ .../components/trend/binary_sensor.py | 102 ++++++----- homeassistant/components/trend/config_flow.py | 111 ++++++++++++ homeassistant/components/trend/const.py | 5 + homeassistant/components/trend/manifest.json | 2 + homeassistant/components/trend/strings.json | 38 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- tests/components/trend/conftest.py | 51 ++++++ tests/components/trend/test_binary_sensor.py | 160 +++++++++--------- tests/components/trend/test_config_flow.py | 80 +++++++++ tests/components/trend/test_init.py | 50 ++++++ 12 files changed, 508 insertions(+), 126 deletions(-) create mode 100644 homeassistant/components/trend/config_flow.py create mode 100644 tests/components/trend/conftest.py create mode 100644 tests/components/trend/test_config_flow.py create mode 100644 tests/components/trend/test_init.py diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index b583f424da1..91d50bcc928 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -1,5 +1,27 @@ """A sensor that monitors trends in other components.""" +from __future__ import annotations +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trend from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an Trend options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index fa6ad8e5382..c86fb65e966 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -54,6 +55,10 @@ from .const import ( CONF_MIN_GRADIENT, CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, DOMAIN, ) @@ -101,40 +106,52 @@ async def async_setup_platform( """Set up the trend sensors.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensors = [] - - for device_id, device_config in config[CONF_SENSORS].items(): - entity_id = device_config[ATTR_ENTITY_ID] - attribute = device_config.get(CONF_ATTRIBUTE) - device_class = device_config.get(CONF_DEVICE_CLASS) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) - invert = device_config[CONF_INVERT] - max_samples = device_config[CONF_MAX_SAMPLES] - min_gradient = device_config[CONF_MIN_GRADIENT] - sample_duration = device_config[CONF_SAMPLE_DURATION] - min_samples = device_config[CONF_MIN_SAMPLES] - - sensors.append( + entities = [] + for sensor_name, sensor_config in config[CONF_SENSORS].items(): + entities.append( SensorTrend( - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, - min_samples, + name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), + entity_id=sensor_config[CONF_ENTITY_ID], + attribute=sensor_config.get(CONF_ATTRIBUTE), + invert=sensor_config[CONF_INVERT], + sample_duration=sensor_config[CONF_SAMPLE_DURATION], + min_gradient=sensor_config[CONF_MIN_GRADIENT], + min_samples=sensor_config[CONF_MIN_SAMPLES], + max_samples=sensor_config[CONF_MAX_SAMPLES], + device_class=sensor_config.get(CONF_DEVICE_CLASS), + sensor_entity_id=generate_entity_id( + ENTITY_ID_FORMAT, sensor_name, hass=hass + ), ) ) - if not sensors: - _LOGGER.error("No sensors added") - return + async_add_entities(entities) - async_add_entities(sensors) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up trend sensor from config entry.""" + + async_add_entities( + [ + SensorTrend( + name=entry.title, + entity_id=entry.options[CONF_ENTITY_ID], + attribute=entry.options.get(CONF_ATTRIBUTE), + invert=entry.options[CONF_INVERT], + sample_duration=entry.options.get( + CONF_SAMPLE_DURATION, DEFAULT_SAMPLE_DURATION + ), + min_gradient=entry.options.get(CONF_MIN_GRADIENT, DEFAULT_MIN_GRADIENT), + min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), + max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), + unique_id=entry.entry_id, + ) + ] + ) class SensorTrend(BinarySensorEntity, RestoreEntity): @@ -146,30 +163,33 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, - hass: HomeAssistant, - device_id: str, - friendly_name: str, + name: str, entity_id: str, - attribute: str, - device_class: BinarySensorDeviceClass, + attribute: str | None, invert: bool, - max_samples: int, - min_gradient: float, sample_duration: int, + min_gradient: float, min_samples: int, + max_samples: int, + unique_id: str | None = None, + device_class: BinarySensorDeviceClass | None = None, + sensor_entity_id: str | None = None, ) -> None: """Initialize the sensor.""" - self._hass = hass - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._attr_name = friendly_name - self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient self._min_samples = min_samples - self.samples: deque = deque(maxlen=max_samples) + self.samples: deque = deque(maxlen=int(max_samples)) + + self._attr_name = name + self._attr_device_class = device_class + self._attr_unique_id = unique_id + + if sensor_entity_id: + self.entity_id = sensor_entity_id @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py new file mode 100644 index 00000000000..457522dca82 --- /dev/null +++ b/homeassistant/components/trend/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Trend integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) + +from .const import ( + CONF_INVERT, + CONF_MAX_SAMPLES, + CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, + CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, + DOMAIN, +) + + +async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get base options schema.""" + return vol.Schema( + { + vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector( + selector.AttributeSelectorConfig( + entity_id=handler.options[CONF_ENTITY_ID] + ) + ), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), + } + ) + + +async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get extended options schema.""" + return (await get_base_options_schema(handler)).extend( + { + vol.Optional( + CONF_MAX_SAMPLES, default=DEFAULT_MAX_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_SAMPLES, default=DEFAULT_MIN_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + step="any", + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_SAMPLE_DURATION, default=DEFAULT_SAMPLE_DURATION + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=UnitOfTime.SECONDS, + ), + ), + } + ) + + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False), + ), + } +) + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Trend.""" + + config_flow = { + "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), + "settings": SchemaFlowFormStep(get_base_options_schema), + } + options_flow = { + "init": SchemaFlowFormStep(get_extended_options_schema), + } + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 3d82bfcc648..838056bfc4d 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -13,3 +13,8 @@ CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" CONF_MIN_SAMPLES = "min_samples" + +DEFAULT_MAX_SAMPLES = 2 +DEFAULT_MIN_SAMPLES = 2 +DEFAULT_MIN_GRADIENT = 0.0 +DEFAULT_SAMPLE_DURATION = 0 diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 0adbf623346..110bab99e52 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,9 @@ "domain": "trend", "name": "Trend", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trend", + "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", "requirements": ["numpy==1.26.0"] diff --git a/homeassistant/components/trend/strings.json b/homeassistant/components/trend/strings.json index 6af231bb4c5..2fe0b35ee3c 100644 --- a/homeassistant/components/trend/strings.json +++ b/homeassistant/components/trend/strings.json @@ -4,5 +4,43 @@ "name": "[%key:common::action::reload%]", "description": "Reloads trend sensors from the YAML-configuration." } + }, + "config": { + "step": { + "user": { + "title": "Trend helper", + "description": "The trend helper allows you to create a sensor which show the trend of a numeric state or a state attribute from another entity.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity that this sensor tracks" + } + }, + "settings": { + "data": { + "attribute": "Attribute of entity that this sensor tracks", + "invert": "Invert the result" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "[%key:component::trend::config::step::settings::data::attribute%]", + "invert": "[%key:component::trend::config::step::settings::data::invert%]", + "max_samples": "Maximum number of stored samples", + "min_samples": "Minimum number of stored samples", + "min_gradient": "Minimum rate at which the value must be changing", + "sample_duration": "Duration in seconds to store samples for" + }, + "data_description": { + "max_samples": "The maximum number of samples to store. If the number of samples exceeds this value, the oldest samples will be discarded.", + "min_samples": "The minimum number of samples that must be collected before the gradient can be calculated.", + "min_gradient": "The minimum rate at which the observed value must be changing for this sensor to switch on. The gradient is measured in sensor units per second.", + "sample_duration": "The duration in seconds to store samples for. Samples older than this value will be discarded." + } + } + } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7da240ac266..cba1a88d25b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ FLOWS = { "template", "threshold", "tod", + "trend", "utility_meter", ], "integration": [ diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3a1e154facb..995609ec226 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6113,12 +6113,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "trend": { - "name": "Trend", - "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" - }, "tuya": { "name": "Tuya", "integration_type": "hub", @@ -6944,6 +6938,12 @@ "config_flow": true, "iot_class": "calculated" }, + "trend": { + "name": "Trend", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "utility_meter": { "integration_type": "helper", "config_flow": true, diff --git a/tests/components/trend/conftest.py b/tests/components/trend/conftest.py new file mode 100644 index 00000000000..cff3831658a --- /dev/null +++ b/tests/components/trend/conftest.py @@ -0,0 +1,51 @@ +"""Fixtures for the trend component tests.""" +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.trend.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[dict[str, Any]], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.cpu_temp", + "invert": False, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + title="My trend", + ) + + +@pytest.fixture(name="setup_component") +async def mock_setup_component( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Set up the trend component.""" + + async def _setup_func(component_params: dict[str, Any]) -> None: + config_entry.title = "test_trend_sensor" + config_entry.options = { + **config_entry.options, + **component_params, + "name": "test_trend_sensor", + "entity_id": "sensor.test_state", + } + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return _setup_func diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index b525c7a8fa3..115bac5ed5d 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -2,22 +2,22 @@ from datetime import timedelta import logging from typing import Any -from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant import config as hass_config, setup -from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant import setup +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_fixture_path, mock_restore_cache +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache -async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: - """Set up the trend component.""" +async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component the legacy way.""" assert await async_setup_component( hass, "binary_sensor", @@ -43,17 +43,54 @@ async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: ], ids=["up", "down", "up inverted", "down inverted"], ) -async def test_basic_trend( +async def test_basic_trend_setup_from_yaml( hass: HomeAssistant, states: list[str], inverted: bool, expected_state: str, -): +) -> None: """Test trend with a basic setup.""" - await _setup_component( + await _setup_legacy_component( hass, { - "entity_id": "sensor.test_state", + "friendly_name": "Test state", + "entity_id": "sensor.cpu_temp", + "invert": inverted, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + ) + + for state in states: + hass.states.async_set("sensor.cpu_temp", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], +) +async def test_basic_trend( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + states: list[str], + inverted: bool, + expected_state: str, +) -> None: + """Test trend with a basic setup.""" + await setup_component( + { "invert": inverted, }, ) @@ -89,16 +126,16 @@ async def test_basic_trend( ) async def test_using_trendline( hass: HomeAssistant, + config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, state_series: list[list[str]], inverted: bool, expected_states: list[str], -): +) -> None: """Test uptrend using multiple samples and trendline calculation.""" - await _setup_component( - hass, + await setup_component( { - "entity_id": "sensor.test_state", "sample_duration": 10000, "min_gradient": 1, "max_samples": 25, @@ -127,12 +164,13 @@ async def test_using_trendline( ) async def test_attribute_trend( hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, attr_values: list[str], expected_state: str, -): +) -> None: """Test attribute uptrend.""" - await _setup_component( - hass, + await setup_component( { "entity_id": "sensor.test_state", "attribute": "attr", @@ -147,12 +185,12 @@ async def test_attribute_trend( assert sensor_state.state == expected_state -async def test_max_samples(hass: HomeAssistant): +async def test_max_samples( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: """Test that sample count is limited correctly.""" - await _setup_component( - hass, + await setup_component( { - "entity_id": "sensor.test_state", "max_samples": 3, "min_gradient": -1, }, @@ -167,39 +205,39 @@ async def test_max_samples(hass: HomeAssistant): assert state.attributes["sample_count"] == 3 -async def test_non_numeric(hass: HomeAssistant): +async def test_non_numeric( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: """Test for non-numeric sensor.""" - await _setup_component(hass, {"entity_id": "sensor.test_state"}) + await setup_component({"entity_id": "sensor.test_state"}) - hass.states.async_set("sensor.test_state", "Non") - await hass.async_block_till_done() - hass.states.async_set("sensor.test_state", "Numeric") - await hass.async_block_till_done() + for val in ["Non", "Numeric"]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() assert (state := hass.states.get("binary_sensor.test_trend_sensor")) assert state.state == STATE_UNKNOWN -async def test_missing_attribute(hass: HomeAssistant): +async def test_missing_attribute( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: """Test for missing attribute.""" - await _setup_component( - hass, + await setup_component( { - "entity_id": "sensor.test_state", "attribute": "missing", }, ) - hass.states.async_set("sensor.test_state", "State", {"attr": "2"}) - await hass.async_block_till_done() - hass.states.async_set("sensor.test_state", "State", {"attr": "1"}) - await hass.async_block_till_done() + for val in [1, 2]: + hass.states.async_set("sensor.test_state", "State", {"attr": val}) + await hass.async_block_till_done() assert (state := hass.states.get("binary_sensor.test_trend_sensor")) assert state.state == STATE_UNKNOWN -async def test_invalid_name_does_not_create(hass: HomeAssistant): +async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: """Test for invalid name.""" with assert_setup_component(0): assert await setup.async_setup_component( @@ -217,7 +255,7 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant): assert hass.states.async_all("binary_sensor") == [] -async def test_invalid_sensor_does_not_create(hass: HomeAssistant): +async def test_invalid_sensor_does_not_create(hass: HomeAssistant) -> None: """Test invalid sensor.""" with assert_setup_component(0): assert await setup.async_setup_component( @@ -235,7 +273,7 @@ async def test_invalid_sensor_does_not_create(hass: HomeAssistant): assert hass.states.async_all("binary_sensor") == [] -async def test_no_sensors_does_not_create(hass: HomeAssistant): +async def test_no_sensors_does_not_create(hass: HomeAssistant) -> None: """Test no sensors.""" with assert_setup_component(0): assert await setup.async_setup_component( @@ -244,59 +282,23 @@ async def test_no_sensors_does_not_create(hass: HomeAssistant): assert hass.states.async_all("binary_sensor") == [] -async def test_reload(hass: HomeAssistant) -> None: - """Verify we can reload trend sensors.""" - hass.states.async_set("sensor.test_state", 1234) - - await setup.async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 - - assert hass.states.get("binary_sensor.test_trend_sensor") - - yaml_path = get_fixture_path("configuration.yaml", "trend") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 - - assert hass.states.get("binary_sensor.test_trend_sensor") is None - assert hass.states.get("binary_sensor.second_test_trend_sensor") - - @pytest.mark.parametrize( ("saved_state", "restored_state"), [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( hass: HomeAssistant, + config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, saved_state: str, restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - await _setup_component( - hass, + await setup_component( { - "entity_id": "sensor.test_state", "sample_duration": 10000, "min_gradient": 1, "max_samples": 25, @@ -332,7 +334,7 @@ async def test_invalid_min_sample( ) -> None: """Test if error is logged when min_sample is larger than max_samples.""" with caplog.at_level(logging.ERROR): - await _setup_component( + await _setup_legacy_component( hass, { "entity_id": "sensor.test_state", diff --git a/tests/components/trend/test_config_flow.py b/tests/components/trend/test_config_flow.py new file mode 100644 index 00000000000..e81d57ef9e1 --- /dev/null +++ b/tests/components/trend/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Trend config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.trend import async_setup_entry +from homeassistant.components.trend.const import DOMAIN +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["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"name": "CPU Temperature rising", "entity_id": "sensor.cpu_temp"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + + # test step 2 of config flow: settings of trend sensor + with patch( + "homeassistant.components.trend.async_setup_entry", wraps=async_setup_entry + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "invert": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CPU Temperature rising" + assert result["data"] == {} + assert result["options"] == { + "entity_id": "sensor.cpu_temp", + "invert": False, + "name": "CPU Temperature rising", + } + + +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test options flow.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "min_samples": 30, + "max_samples": 50, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "min_samples": 30, + "max_samples": 50, + "entity_id": "sensor.cpu_temp", + "invert": False, + "min_gradient": 0.0, + "name": "My trend", + "sample_duration": 0.0, + } diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py new file mode 100644 index 00000000000..47bcab2214d --- /dev/null +++ b/tests/components/trend/test_init.py @@ -0,0 +1,50 @@ +"""Test the Trend integration.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.trend.conftest import ComponentSetup + + +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and removing a config entry.""" + registry = er.async_get(hass) + trend_entity_id = "binary_sensor.my_trend" + + # Set up the config entry + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(trend_entity_id) is not None + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(trend_entity_id) is None + assert registry.async_get(trend_entity_id) is None + + +async def test_reload_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, +) -> None: + """Test config entry reload.""" + await setup_component({}) + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, "max_samples": 4.0} + ) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data == {**config_entry.data, "max_samples": 4.0} From 83f4d3af5c6f95b33fa98313fb493844bfd4b101 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Dec 2023 14:51:39 +0100 Subject: [PATCH 792/927] Implement mode validation in Climate entity component (#105745) * Implement mode validation in Climate entity component * Fix some tests * more tests * Fix translations * fix deconz tests * Fix switcher_kis tests * not None * Fix homematicip_cloud test * Always validate * Fix shelly * reverse logic in validation * modes_str --------- Co-authored-by: J. Nick Koston --- homeassistant/components/climate/__init__.py | 58 +++++- homeassistant/components/climate/strings.json | 11 ++ homeassistant/components/demo/climate.py | 2 +- tests/components/balboa/test_climate.py | 3 +- tests/components/climate/conftest.py | 22 +++ tests/components/climate/test_init.py | 177 +++++++++++++++++- tests/components/deconz/test_climate.py | 5 +- tests/components/demo/test_climate.py | 8 +- .../generic_thermostat/test_climate.py | 3 +- tests/components/gree/test_climate.py | 7 +- .../homematicip_cloud/test_climate.py | 15 +- .../maxcube/test_maxcube_climate.py | 3 +- tests/components/mqtt/test_climate.py | 11 +- tests/components/nest/test_climate.py | 6 +- tests/components/netatmo/test_climate.py | 18 +- tests/components/sensibo/test_climate.py | 10 +- tests/components/shelly/test_climate.py | 15 +- tests/components/switcher_kis/test_climate.py | 7 +- tests/components/whirlpool/test_climate.py | 3 +- tests/components/zha/test_climate.py | 30 +-- tests/components/zwave_js/test_climate.py | 5 +- 21 files changed, 342 insertions(+), 77 deletions(-) create mode 100644 tests/components/climate/conftest.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index f7d168bfa4a..4815b7a1cbb 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import functools as ft import logging -from typing import TYPE_CHECKING, Any, final +from typing import TYPE_CHECKING, Any, Literal, final import voluptuous as vol @@ -19,7 +19,8 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -166,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [ClimateEntityFeature.PRESET_MODE], ) component.async_register_entity_service( @@ -193,13 +194,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_FAN_MODE, {vol.Required(ATTR_FAN_MODE): cv.string}, - "async_set_fan_mode", + "async_handle_set_fan_mode_service", [ClimateEntityFeature.FAN_MODE], ) component.async_register_entity_service( SERVICE_SET_SWING_MODE, {vol.Required(ATTR_SWING_MODE): cv.string}, - "async_set_swing_mode", + "async_handle_set_swing_mode_service", [ClimateEntityFeature.SWING_MODE], ) @@ -515,6 +516,35 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ return self._attr_swing_modes + @final + @callback + def _valid_mode_or_raise( + self, + mode_type: Literal["preset", "swing", "fan"], + mode: str, + modes: list[str] | None, + ) -> None: + """Raise ServiceValidationError on invalid modes.""" + if modes and mode in modes: + return + modes_str: str = ", ".join(modes) if modes else "" + if mode_type == "preset": + translation_key = "not_valid_preset_mode" + elif mode_type == "swing": + translation_key = "not_valid_swing_mode" + elif mode_type == "fan": + translation_key = "not_valid_fan_mode" + raise ServiceValidationError( + f"The {mode_type}_mode {mode} is not a valid {mode_type}_mode:" + f" {modes_str}", + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={ + "mode": mode, + "modes": modes_str, + }, + ) + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() @@ -533,6 +563,12 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new target humidity.""" await self.hass.async_add_executor_job(self.set_humidity, humidity) + @final + async def async_handle_set_fan_mode_service(self, fan_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("fan", fan_mode, self.fan_modes) + await self.async_set_fan_mode(fan_mode) + def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" raise NotImplementedError() @@ -549,6 +585,12 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new target hvac mode.""" await self.hass.async_add_executor_job(self.set_hvac_mode, hvac_mode) + @final + async def async_handle_set_swing_mode_service(self, swing_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("swing", swing_mode, self.swing_modes) + await self.async_set_swing_mode(swing_mode) + def set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" raise NotImplementedError() @@ -557,6 +599,12 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new target swing operation.""" await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("preset", preset_mode, self.preset_modes) + await self.async_set_preset_mode(preset_mode) + def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" raise NotImplementedError() diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 55ccef2bc76..ef87f287430 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -233,5 +233,16 @@ "heat": "Heat" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." + }, + "not_valid_swing_mode": { + "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." + }, + "not_valid_fan_mode": { + "message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}." + } } } diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 1e585b12acd..0eaa7d5f41f 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -73,7 +73,7 @@ async def async_setup_entry( target_temperature=None, unit_of_measurement=UnitOfTemperature.CELSIUS, preset="home", - preset_modes=["home", "eco"], + preset_modes=["home", "eco", "away"], current_temperature=23, fan_mode="Auto Low", target_humidity=None, diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 90ef6c75e5f..6ba0661ae55 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from . import init_integration @@ -146,7 +147,7 @@ async def test_spa_preset_modes( assert state assert state.attributes[ATTR_PRESET_MODE] == mode - with pytest.raises(KeyError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) # put it in RNR and test assertion diff --git a/tests/components/climate/conftest.py b/tests/components/climate/conftest.py new file mode 100644 index 00000000000..2db96a20a0b --- /dev/null +++ b/tests/components/climate/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Climate platform tests.""" +from collections.abc import Generator + +import pytest + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant + +from tests.common import mock_config_flow, mock_platform + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 1181a432ea2..f46e0902c66 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -10,16 +10,36 @@ import voluptuous as vol from homeassistant.components import climate from homeassistant.components.climate import ( + DOMAIN, SET_TEMPERATURE_SCHEMA, ClimateEntity, HVACMode, ) +from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + ClimateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, async_mock_service, import_and_test_deprecated_constant, import_and_test_deprecated_constant_enum, + mock_integration, + mock_platform, ) @@ -57,9 +77,22 @@ async def test_set_temp_schema( assert calls[-1].data == data -class MockClimateEntity(ClimateEntity): +class MockClimateEntity(MockEntity, ClimateEntity): """Mock Climate device to use in tests.""" + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_preset_mode = "home" + _attr_preset_modes = ["home", "away"] + _attr_fan_mode = "auto" + _attr_fan_modes = ["auto", "off"] + _attr_swing_mode = "auto" + _attr_swing_modes = ["auto", "off"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode. @@ -82,6 +115,18 @@ class MockClimateEntity(ClimateEntity): def turn_off(self) -> None: """Turn off.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + + def set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + self._attr_fan_mode = fan_mode + + def set_swing_mode(self, swing_mode: str) -> None: + """Set swing mode.""" + self._attr_swing_mode = swing_mode + async def test_sync_turn_on(hass: HomeAssistant) -> None: """Test if async turn_on calls sync turn_on.""" @@ -158,3 +203,133 @@ def test_deprecated_current_constants( enum, "2025.1", ) + + +async def test_preset_mode_validation( + hass: HomeAssistant, config_flow_fixture: None +) -> None: + """Test mode validation for fan, swing and preset.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities([MockClimateEntity(name="test", entity_id="climate.test")]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "home" + assert state.attributes.get(ATTR_FAN_MODE) == "auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "away", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "off", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "off", + }, + blocking=True, + ) + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "away" + assert state.attributes.get(ATTR_FAN_MODE) == "off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" + + with pytest.raises( + ServiceValidationError, + match="The preset_mode invalid is not a valid preset_mode: home, away", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "The preset_mode invalid is not a valid preset_mode: home, away" + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises( + ServiceValidationError, + match="The swing_mode invalid is not a valid swing_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) == "The swing_mode invalid is not a valid swing_mode: auto, off" + ) + assert exc.value.translation_key == "not_valid_swing_mode" + + with pytest.raises( + ServiceValidationError, + match="The fan_mode invalid is not a valid fan_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "invalid", + }, + blocking=True, + ) + assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" + assert exc.value.translation_key == "not_valid_fan_mode" diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 5a3952e16db..dd0de559ba8 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -41,6 +41,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_gateway import ( DECONZ_WEB_REQUEST, @@ -602,7 +603,7 @@ async def test_climate_device_with_fan_support( # Service set fan mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -725,7 +726,7 @@ async def test_climate_device_with_preset( # Service set preset to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 69e385ce242..97b436ea2b0 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -278,12 +278,12 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "On Low"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "on_low"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On Low" + assert state.attributes.get(ATTR_FAN_MODE) == "on_low" async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: @@ -311,12 +311,12 @@ async def test_set_swing(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "Auto"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "auto"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" async def test_set_hvac_bad_attr_and_state(hass: HomeAssistant) -> None: diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 47a3cdc30af..9196de8b096 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -41,6 +41,7 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -388,7 +389,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, setup_comp_2) -> Non await common.async_set_preset_mode(hass, "none") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, "Sleep") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index f5af1f403c3..5b261fa266b 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -50,6 +50,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -538,7 +539,7 @@ async def test_send_invalid_preset_mode( """Test for sending preset mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, @@ -699,7 +700,7 @@ async def test_send_invalid_fan_mode( """Test for sending fan mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, @@ -780,7 +781,7 @@ async def test_send_invalid_swing_mode( """Test for sending swing mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index b042e3daa6c..20193d91239 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -3,6 +3,7 @@ import datetime from homematicip.base.enums import AbsenceType from homematicip.functionalHomes import IndoorClimateHome +import pytest from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -23,6 +24,7 @@ from homeassistant.components.homematicip_cloud.climate import ( PERMANENT_END_TIME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from .helper import HAPID, async_manipulate_test_data, get_and_check_entity_basics @@ -340,12 +342,13 @@ async def test_hmip_heating_group_cool( assert ha_state.attributes[ATTR_PRESET_MODE] == "none" assert ha_state.attributes[ATTR_PRESET_MODES] == [] - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": entity_id, "preset_mode": "Cool2"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) assert len(hmip_device.mock_calls) == service_call_counter + 12 # fire_update_event shows that set_active_profile has not been called. diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index f279f049ac3..3f2b325330e 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -50,6 +50,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow @@ -370,7 +371,7 @@ async def test_thermostat_set_invalid_preset( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 6d6c7475366..9bb5c8b2585 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,6 +33,7 @@ from homeassistant.components.mqtt.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_common import ( help_custom_config, @@ -1130,8 +1131,9 @@ async def test_set_preset_mode_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( @@ -1187,8 +1189,9 @@ async def test_set_preset_mode_explicit_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index c920eb5717d..e1c3cc187db 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -39,7 +39,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import ( DEVICE_COMMAND, @@ -1192,7 +1192,7 @@ async def test_thermostat_invalid_fan_mode( assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_fan_mode(hass, FAN_LOW) await hass.async_block_till_done() @@ -1474,7 +1474,7 @@ async def test_thermostat_invalid_set_preset_mode( assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] # Set preset mode that is invalid - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, PRESET_SLEEP) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 848aad331bd..11e2077f859 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -33,6 +33,7 @@ from homeassistant.components.netatmo.const import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook @@ -879,15 +880,14 @@ async def test_service_preset_mode_invalid( await hass.async_block_till_done() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, - blocking=True, - ) - await hass.async_block_till_done() - - assert "Preset mode 'invalid' not available" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() async def test_valves_service_turn_off( diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 71680733098..bf0113cb22b 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -1330,10 +1330,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate swing mode faulty_swing_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, @@ -1343,10 +1340,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate fan mode faulty_fan_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index f52b542b389..980981de754 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -382,12 +382,13 @@ async def test_block_restored_climate_set_preset_before_online( assert hass.states.get(entity_id).state == HVACMode.HEAT - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, + blocking=True, + ) mock_block_device.http_request.assert_not_called() diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index f998dbe294b..1919261109e 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import slugify from . import init_integration @@ -336,9 +336,8 @@ async def test_climate_control_errors( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, blocking=True, ) - # Test exception when trying set fan level - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -347,7 +346,7 @@ async def test_climate_control_errors( ) # Test exception when trying set swing mode - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index fe2f9f17504..8607a49b42c 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -43,6 +43,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import init_integration @@ -337,7 +338,7 @@ async def test_service_calls( mock_instance.set_fanspeed.reset_mock() # FAN_MIDDLE is not supported - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 145aba799ca..b693c034199 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -52,7 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -860,12 +860,13 @@ async def test_preset_setting_invalid( state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -1251,13 +1252,14 @@ async def test_set_fan_mode_not_supported( entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, - blocking=True, - ) - assert fan_cluster.write_attributes.await_count == 0 + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + assert fan_cluster.write_attributes.await_count == 0 async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index d5619ff014c..e4550b7f961 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -40,6 +40,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .common import ( @@ -278,7 +279,7 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() # Test setting invalid fan mode - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -692,7 +693,7 @@ async def test_preset_and_no_setpoint( assert state.attributes[ATTR_TEMPERATURE] is None assert state.attributes[ATTR_PRESET_MODE] == "Full power" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Test setting invalid preset mode await hass.services.async_call( CLIMATE_DOMAIN, From ed3ea5e5f402f5ca545da64f80cb2f2aedba0169 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 27 Dec 2023 15:08:58 +0100 Subject: [PATCH 793/927] Add device to swiss public transport (#106475) add to device registry --- .../swiss_public_transport/sensor.py | 23 +++++++++++++++---- .../swiss_public_transport/strings.json | 7 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 63b5891e48d..47954229a1f 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -13,6 +14,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -42,10 +44,13 @@ async def async_setup_entry( """Set up the sensor from a config entry created in the integrations UI.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - name = config_entry.title + unique_id = config_entry.unique_id + + if TYPE_CHECKING: + assert unique_id async_add_entities( - [SwissPublicTransportSensor(coordinator, name)], + [SwissPublicTransportSensor(coordinator, unique_id)], True, ) @@ -101,14 +106,22 @@ class SwissPublicTransportSensor( _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" + _attr_has_entity_name = True + _attr_translation_key = "departure" def __init__( - self, coordinator: SwissPublicTransportDataUpdateCoordinator, name: str + self, + coordinator: SwissPublicTransportDataUpdateCoordinator, + unique_id: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._coordinator = coordinator - self._attr_name = name + self._attr_unique_id = f"{unique_id}_departure" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Opendata.ch", + entry_type=DeviceEntryType.SERVICE, + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 01736beba78..6d0eb53ad11 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -22,6 +22,13 @@ } } }, + "entity": { + "sensor": { + "departure": { + "name": "Departure" + } + } + }, "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The swiss public transport YAML configuration import cannot connect to server", From a823edf1c2743a7f91f97ab30d327facac00fba4 Mon Sep 17 00:00:00 2001 From: Martijn van der Pol Date: Wed, 27 Dec 2023 15:14:20 +0100 Subject: [PATCH 794/927] Jinja filter and function for `median` and `statistical_mode` (#105554) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- homeassistant/helpers/template.py | 68 +++++++++++++++++++++++++ tests/helpers/test_template.py | 82 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9bb3759672f..f96b2c53b50 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1908,6 +1908,66 @@ def average(*args: Any, default: Any = _SENTINEL) -> Any: return default +def median(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the median. + + Calculates median of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if len(args) == 0: + raise TypeError("median expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if isinstance(args[0], Iterable): + median_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + median_list = args + + try: + return statistics.median(median_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("median", args) + return default + + +def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the statistical mode. + + Calculates mode of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if not args: + raise TypeError("statistical_mode expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if len(args) == 1 and isinstance(args[0], Iterable): + mode_list = args[0] + elif isinstance(args[0], list | tuple): + mode_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + mode_list = args + + try: + return statistics.mode(mode_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("statistical_mode", args) + return default + + def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: @@ -2390,6 +2450,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["from_json"] = from_json self.filters["is_defined"] = fail_when_undefined self.filters["average"] = average + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.filters["random"] = random_every_time self.filters["base64_encode"] = base64_encode self.filters["base64_decode"] = base64_decode @@ -2412,6 +2474,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2433,6 +2497,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode self.globals["average"] = average + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number @@ -2445,6 +2511,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean self.globals["version"] = version + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index cdb272e2d97..b70c9479abb 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1318,6 +1318,88 @@ def test_average(hass: HomeAssistant) -> None: template.Template("{{ average([]) }}", hass).async_render() +def test_median(hass: HomeAssistant) -> None: + """Test the median filter.""" + assert template.Template("{{ [1, 3, 2] | median }}", hass).async_render() == 2 + assert template.Template("{{ median([1, 3, 2, 4]) }}", hass).async_render() == 2.5 + assert template.Template("{{ median(1, 3, 2) }}", hass).async_render() == 2 + assert template.Template("{{ median('cdeba') }}", hass).async_render() == "c" + + # Testing of default values + assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 + assert template.Template("{{ median('abcd', -1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ median([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ median(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | median }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median([]) }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median('abcd') }}", hass).async_render() + + +def test_statistical_mode(hass: HomeAssistant) -> None: + """Test the mode filter.""" + assert ( + template.Template("{{ [1, 2, 2, 3] | statistical_mode }}", hass).async_render() + == 2 + ) + assert ( + template.Template("{{ statistical_mode([1, 2, 3]) }}", hass).async_render() == 1 + ) + assert ( + template.Template( + "{{ statistical_mode('hello', 'bye', 'hello') }}", hass + ).async_render() + == "hello" + ) + assert ( + template.Template("{{ statistical_mode('banana') }}", hass).async_render() + == "a" + ) + + # Testing of default values + assert ( + template.Template("{{ statistical_mode([1, 2, 3], -1) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() + == -1 + ) + assert ( + template.Template( + "{{ statistical_mode([], 5, default=-1) }}", hass + ).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | statistical_mode }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode([]) }}", hass).async_render() + + def test_min(hass: HomeAssistant) -> None: """Test the min filter.""" assert template.Template("{{ [1, 2, 3] | min }}", hass).async_render() == 1 From 1d9a2b53e733156397a97e8c885d82464d90bf8c Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 27 Dec 2023 15:28:59 +0100 Subject: [PATCH 795/927] Remove initial entity update from swiss_public_transport (#106478) remove initial update --- homeassistant/components/swiss_public_transport/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 47954229a1f..5d4a6813d2d 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -51,7 +51,6 @@ async def async_setup_entry( async_add_entities( [SwissPublicTransportSensor(coordinator, unique_id)], - True, ) From 45fde2db4e0b93995313c76cee462130130b5bc0 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Wed, 27 Dec 2023 06:43:00 -0800 Subject: [PATCH 796/927] Remove hardcoded bits from LiteJet integration (#106281) Use the new properties from pylitejet v0.6.0. --- homeassistant/components/litejet/diagnostics.py | 1 + homeassistant/components/litejet/scene.py | 2 +- homeassistant/components/litejet/switch.py | 4 ++-- tests/components/litejet/conftest.py | 11 ++++++++++- tests/components/litejet/test_diagnostics.py | 1 + 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/litejet/diagnostics.py b/homeassistant/components/litejet/diagnostics.py index b996dcc0413..48f38542dfd 100644 --- a/homeassistant/components/litejet/diagnostics.py +++ b/homeassistant/components/litejet/diagnostics.py @@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for LiteJet config entry.""" system: LiteJet = hass.data[DOMAIN] return { + "model": system.model_name, "loads": list(system.loads()), "button_switches": list(system.button_switches()), "scenes": list(system.scenes()), diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index ce04a537559..ec8e4d697fe 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -51,7 +51,7 @@ class LiteJetScene(Scene): identifiers={(DOMAIN, f"{entry_id}_mcp")}, name="LiteJet", manufacturer="Centralite", - model="CL24", + model=system.model_name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 025770cdc35..5089b9ec0f9 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -49,10 +49,10 @@ class LiteJetSwitch(SwitchEntity): self._attr_name = name # Keypad #1 has switches 1-6, #2 has 7-12, ... - keypad_number = int((i - 1) / 6) + 1 + keypad_number = system.get_switch_keypad_number(i) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, - name=f"Keypad #{keypad_number}", + name=system.get_switch_keypad_name(i), manufacturer="Centralite", via_device=(DOMAIN, f"{entry_id}_mcp"), ) diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 3bbd9ef4ef0..2c631265c30 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -1,6 +1,6 @@ """Fixtures for LiteJet testing.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -21,6 +21,12 @@ def mock_litejet(): async def get_switch_name(number): return f"Mock Switch #{number}" + def get_switch_keypad_number(number): + return number + 100 + + def get_switch_keypad_name(number): + return f"Mock Keypad #{number + 100}" + mock_lj = mock_pylitejet.return_value mock_lj.switch_pressed_callbacks = {} @@ -65,6 +71,8 @@ def mock_litejet(): mock_lj.get_switch_name = AsyncMock(side_effect=get_switch_name) mock_lj.press_switch = AsyncMock() mock_lj.release_switch = AsyncMock() + mock_lj.get_switch_keypad_number = Mock(side_effect=get_switch_keypad_number) + mock_lj.get_switch_keypad_name = Mock(side_effect=get_switch_keypad_name) mock_lj.scenes.return_value = range(1, 3) mock_lj.get_scene_name = AsyncMock(side_effect=get_scene_name) @@ -74,6 +82,7 @@ def mock_litejet(): mock_lj.start_time = dt_util.utcnow() mock_lj.last_delta = timedelta(0) mock_lj.connected = True + mock_lj.model_name = "MockJet" def connected_changed(connected: bool, reason: str) -> None: mock_lj.connected = connected diff --git a/tests/components/litejet/test_diagnostics.py b/tests/components/litejet/test_diagnostics.py index 368cdf557c8..a2c8bc72476 100644 --- a/tests/components/litejet/test_diagnostics.py +++ b/tests/components/litejet/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_diagnostics( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { + "model": "MockJet", "loads": [1, 2], "button_switches": [1, 2], "scenes": [1, 2], From 817c71747fbefa1cd5f90f4d64649530c62ff8b3 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 27 Dec 2023 10:25:41 -0500 Subject: [PATCH 797/927] Delay all ZHA polling until initialization of entities has completed (#105814) * Don't update entities until they are initialized * fix hass reference * only establish polling once * fix log level and small cleanup * start device availability checks after full initialization of network * add logging * clean up sensor polling and class hierarchy * don't attempt restore sensor cleanup in this PR * put check back * fix race condition and remove parallel updates * add sensor polling test * cleanup switch polling and add a test * clean up and actually fix race condition * update light forced refresh * only use flag * unused flag * reduce diff size * collapse --- homeassistant/components/zha/core/device.py | 60 +++++------ homeassistant/components/zha/core/gateway.py | 5 + homeassistant/components/zha/core/helpers.py | 1 + homeassistant/components/zha/light.py | 29 +++++- homeassistant/components/zha/sensor.py | 100 ++++++++++++++----- homeassistant/components/zha/switch.py | 27 +++-- tests/components/zha/test_sensor.py | 44 +++++++- tests/components/zha/test_switch.py | 11 ++ 8 files changed, 204 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 0ce6f47b61e..1a3d3a2da1f 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -166,6 +166,9 @@ class ZHADevice(LogMixin): if not self.is_coordinator: keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.debug( + "starting availability checks - interval: %s", keep_alive_interval + ) self.unsubs.append( async_track_time_interval( self.hass, @@ -447,35 +450,36 @@ class ZHADevice(LogMixin): self._checkins_missed_count = 0 return - if ( - self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS - or self.manufacturer == "LUMI" - or not self._endpoints - ): - self.debug( - ( - "last_seen is %s seconds ago and ping attempts have been exhausted," - " marking the device unavailable" - ), - difference, - ) - self.update_available(False) - return + if self.hass.data[const.DATA_ZHA].allow_polling: + if ( + self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS + or self.manufacturer == "LUMI" + or not self._endpoints + ): + self.debug( + ( + "last_seen is %s seconds ago and ping attempts have been exhausted," + " marking the device unavailable" + ), + difference, + ) + self.update_available(False) + return - self._checkins_missed_count += 1 - self.debug( - "Attempting to checkin with device - missed checkins: %s", - self._checkins_missed_count, - ) - if not self.basic_ch: - self.debug("does not have a mandatory basic cluster") - self.update_available(False) - return - res = await self.basic_ch.get_attribute_value( - ATTR_MANUFACTURER, from_cache=False - ) - if res is not None: - self._checkins_missed_count = 0 + self._checkins_missed_count += 1 + self.debug( + "Attempting to checkin with device - missed checkins: %s", + self._checkins_missed_count, + ) + if not self.basic_ch: + self.debug("does not have a mandatory basic cluster") + self.update_available(False) + return + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) + if res is not None: + self._checkins_missed_count = 0 def update_available(self, available: bool) -> None: """Update device availability and signal entities.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 6c461ac45c3..1308abb3d37 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -47,6 +47,7 @@ from .const import ( ATTR_TYPE, CONF_RADIO_TYPE, CONF_ZIGPY, + DATA_ZHA, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -292,6 +293,10 @@ class ZHAGateway: if dev.is_mains_powered ) ) + _LOGGER.debug( + "completed fetching current state for mains powered devices - allowing polled requests" + ) + self.hass.data[DATA_ZHA].allow_polling = True # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 0246c1e4b1c..bb87cb2cf58 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -442,6 +442,7 @@ class ZHAData: device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( default_factory=dict ) + allow_polling: bool = dataclasses.field(default=False) def get_zha_data(hass: HomeAssistant) -> ZHAData: diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index d545a331a6d..486b043b450 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,6 +47,7 @@ from .core.const import ( CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, @@ -75,7 +76,6 @@ FLASH_EFFECTS = { STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) -PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" @@ -788,6 +788,7 @@ class Light(BaseLight, ZhaEntity): self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) + self.debug("started polling with refresh interval of %s", refresh_interval) self.async_accept_signal( None, SIGNAL_LIGHT_GROUP_STATE_CHANGED, @@ -838,6 +839,8 @@ class Light(BaseLight, ZhaEntity): """Disconnect entity object when removed.""" assert self._cancel_refresh_handle self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") await super().async_will_remove_from_hass() @callback @@ -980,8 +983,16 @@ class Light(BaseLight, ZhaEntity): if self.is_transitioning: self.debug("skipping _refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) async def _maybe_force_refresh(self, signal): """Force update the state if the signal contains the entity id for this entity.""" @@ -989,8 +1000,16 @@ class Light(BaseLight, ZhaEntity): if self.is_transitioning: self.debug("skipping _maybe_force_refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("forcing polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping _maybe_force_refresh, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) @callback def _assume_group_state(self, signal, update_params) -> None: diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 4ec4c11ef53..027e710e30c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,9 +1,11 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +from datetime import timedelta import enum import functools import numbers +import random from typing import TYPE_CHECKING, Any, Self from zigpy import types @@ -37,9 +39,10 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import StateType from .core import discovery @@ -57,6 +60,7 @@ from .core.const import ( CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -68,8 +72,6 @@ if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice -PARALLEL_UPDATES = 5 - BATTERY_SIZES = { 0: "No battery", 1: "Built in", @@ -185,6 +187,55 @@ class Sensor(ZhaEntity, SensorEntity): return round(float(value * self._multiplier) / self._divisor) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PollableSensor(Sensor): + """Base ZHA sensor that polls for state.""" + + _use_custom_polling: bool = True + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cancel_refresh_handle: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._use_custom_polling: + refresh_interval = random.randint(30, 60) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(seconds=refresh_interval) + ) + self.debug("started polling with refresh interval of %s", refresh_interval) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle + self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") + await super().async_will_remove_from_hass() + + async def _refresh(self, time): + """Call async_update at a constrained random interval.""" + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_update() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", @@ -258,9 +309,10 @@ class Battery(Sensor): models={"VZM31-SN", "SP 234", "outletv4"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurement(Sensor): +class ElectricalMeasurement(PollableSensor): """Active power measurement.""" + _use_custom_polling: bool = False _attribute_name = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -306,22 +358,17 @@ class ElectricalMeasurement(Sensor): class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await super().async_update() + _use_custom_polling: bool = True @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementApparentPower(ElectricalMeasurement): +class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): """Apparent power measurement.""" _attribute_name = "apparent_power" _unique_id_suffix = "apparent_power" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -329,11 +376,12 @@ class ElectricalMeasurementApparentPower(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): +class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): """RMS current measurement.""" _attribute_name = "rms_current" _unique_id_suffix = "rms_current" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @@ -341,11 +389,12 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): +class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): """RMS Voltage measurement.""" _attribute_name = "rms_voltage" _unique_id_suffix = "rms_voltage" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @@ -353,11 +402,12 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementFrequency(ElectricalMeasurement): +class ElectricalMeasurementFrequency(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "ac_frequency" _unique_id_suffix = "ac_frequency" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_translation_key: str = "ac_frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ @@ -366,11 +416,12 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementPowerFactor(ElectricalMeasurement): +class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "power_factor" _unique_id_suffix = "power_factor" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_native_unit_of_measurement = PERCENTAGE @@ -440,9 +491,10 @@ class Illuminance(Sensor): stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartEnergyMetering(Sensor): +class SmartEnergyMetering(PollableSensor): """Metering sensor.""" + _use_custom_polling: bool = False _attribute_name = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -540,13 +592,7 @@ class SmartEnergySummation(SmartEnergyMetering): class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await self._cluster_handler.async_force_update() + _use_custom_polling: bool = True @MULTI_MATCH( @@ -557,6 +603,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier1_summ_delivered" _unique_id_suffix = "tier1_summation_delivered" _attr_translation_key: str = "tier1_summation_delivered" @@ -570,6 +617,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier2_summ_delivered" _unique_id_suffix = "tier2_summation_delivered" _attr_translation_key: str = "tier2_summation_delivered" @@ -583,6 +631,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier3_summ_delivered" _unique_id_suffix = "tier3_summation_delivered" _attr_translation_key: str = "tier3_summation_delivered" @@ -596,6 +645,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier4_summ_delivered" _unique_id_suffix = "tier4_summation_delivered" _attr_translation_key: str = "tier4_summation_delivered" @@ -609,6 +659,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier5_summ_delivered" _unique_id_suffix = "tier5_summation_delivered" _attr_translation_key: str = "tier5_summation_delivered" @@ -622,6 +673,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier6_summ_delivered" _unique_id_suffix = "tier6_summation_delivered" _attr_translation_key: str = "tier6_summation_delivered" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 71c6e9d90ad..d4e835751f5 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -108,11 +108,10 @@ class Switch(ZhaEntity, SwitchEntity): async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" - await super().async_update() - if self._on_off_cluster_handler: - await self._on_off_cluster_handler.get_attribute_value( - "on_off", from_cache=False - ) + self.debug("Polling current state") + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) @GROUP_MATCH() @@ -255,16 +254,14 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" - await super().async_update() - self.error("Polling current state") - if self._cluster_handler: - value = await self._cluster_handler.get_attribute_value( - self._attribute_name, from_cache=False - ) - await self._cluster_handler.get_attribute_value( - self._inverter_attribute_name, from_cache=False - ) - self.debug("read value=%s, inverted=%s", value, self.inverted) + self.debug("Polling current state") + value = await self._cluster_handler.get_attribute_value( + self._attribute_name, from_cache=False + ) + await self._cluster_handler.get_attribute_value( + self._inverter_attribute_name, from_cache=False + ) + self.debug("read value=%s, inverted=%s", value, self.inverted) @CONFIG_DIAGNOSTIC_MATCH( diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 59b8bb1293e..d9a61b12357 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,4 +1,5 @@ """Test ZHA sensor.""" +from datetime import timedelta import math from unittest.mock import MagicMock, patch @@ -47,7 +48,10 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_mock_load_restore_state_from_storage +from tests.common import ( + async_fire_time_changed, + async_mock_load_restore_state_from_storage, +) ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -921,6 +925,44 @@ async def test_elec_measurement_sensor_type( assert state.attributes["measurement_type"] == expected_type +async def test_elec_measurement_sensor_polling( + hass: HomeAssistant, + elec_measurement_zigpy_dev, + zha_device_joined_restored, +) -> None: + """Test ZHA electrical measurement sensor polling.""" + + entity_id = ENTITY_ID_PREFIX.format("power") + zigpy_dev = elec_measurement_zigpy_dev + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 20 + + await zha_device_joined_restored(zigpy_dev) + + # test that the sensor has an initial state of 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # update the value for the power reading + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 60 + + # ensure the state is still 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # let the polling happen + future = dt_util.utcnow() + timedelta(seconds=90) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # ensure the state has been updated to 6.0 + state = hass.states.get(entity_id) + assert state.state == "6.0" + + @pytest.mark.parametrize( "supported_attributes", ( diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index b07b34763d1..0db9b7dd18e 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -197,6 +197,17 @@ async def test_switch( tsn=None, ) + await async_setup_component(hass, "homeassistant", {}) + + cluster.read_attributes.reset_mock() + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert len(cluster.read_attributes.mock_calls) == 1 + assert cluster.read_attributes.call_args == call( + ["on_off"], allow_cache=False, only_cache=False, manufacturer=None + ) + # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device, [cluster], (1,)) From a6d8a82f3ee0ab3b004a63b23ed625ae8be527a3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Dec 2023 16:47:59 +0100 Subject: [PATCH 798/927] Add Comelit alarm zones sensor (#106421) * Add Comelit alarm zones sensor * apply review comment * add translation key * capitalize * cleanup * apply review comment * apply review comment * more review comment --- homeassistant/components/comelit/__init__.py | 1 + .../components/comelit/coordinator.py | 5 +- homeassistant/components/comelit/sensor.py | 109 ++++++++++++++++-- homeassistant/components/comelit/strings.json | 17 +++ 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 924d3bee4bb..c51081196c9 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -18,6 +18,7 @@ BRIDGE_PLATFORMS = [ ] VEDO_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, + Platform.SENSOR, ] diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 377ffec4ba4..6559e2ffb87 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -8,6 +8,7 @@ from aiocomelit import ( ComelitSerialBridgeObject, ComelitVedoApi, ComelitVedoAreaObject, + ComelitVedoZoneObject, exceptions, ) from aiocomelit.api import ComelitCommonApi @@ -53,7 +54,9 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): def platform_device_info( self, - object_class: ComelitVedoAreaObject | ComelitSerialBridgeObject, + object_class: ComelitVedoZoneObject + | ComelitVedoAreaObject + | ComelitSerialBridgeObject, object_type: str, ) -> dr.DeviceInfo: """Set platform device info.""" diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 79b1db98356..66b04e6ae98 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Final -from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import OTHER +from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,16 +12,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitSerialBridge, ComelitVedoSystem -SENSOR_TYPES: Final = ( +SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( key="power", native_unit_of_measurement=UnitOfPower.WATT, @@ -29,6 +29,17 @@ SENSOR_TYPES: Final = ( ), ) +SENSOR_VEDO_TYPES: Final = ( + SensorEntityDescription( + key="human_status", + translation_key="zone_status", + name=None, + device_class=SensorDeviceClass.ENUM, + icon="mdi:shield-check", + options=[zone_state.value for zone_state in AlarmZoneState], + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -37,23 +48,57 @@ async def async_setup_entry( ) -> None: """Set up Comelit sensors.""" + if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + await async_setup_bridge_entry(hass, config_entry, async_add_entities) + else: + await async_setup_vedo_entry(hass, config_entry, async_add_entities) + + +async def async_setup_bridge_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit Bridge sensors.""" + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - entities: list[ComelitSensorEntity] = [] + entities: list[ComelitBridgeSensorEntity] = [] for device in coordinator.data[OTHER].values(): entities.extend( - ComelitSensorEntity(coordinator, device, config_entry.entry_id, sensor_desc) - for sensor_desc in SENSOR_TYPES + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_BRIDGE_TYPES ) - async_add_entities(entities) -class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +async def async_setup_vedo_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit VEDO sensors.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitVedoSensorEntity] = [] + for device in coordinator.data[ALARM_ZONES].values(): + entities.extend( + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_VEDO_TYPES + ) + async_add_entities(entities) + + +class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): """Sensor device.""" _attr_has_entity_name = True - entity_description: SensorEntityDescription + _attr_name = None def __init__( self, @@ -80,3 +125,45 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): self.coordinator.data[OTHER][self._device.index], self.entity_description.key, ) + + +class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity): + """Sensor device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitVedoSystem, + zone: ComelitVedoZoneObject, + config_entry_entry_id: str, + description: SensorEntityDescription, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._zone = zone + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}" + self._attr_device_info = coordinator.platform_device_info(zone, "zone") + + self.entity_description = description + + @property + def _zone_object(self) -> ComelitVedoZoneObject: + """Zone object.""" + return self.coordinator.data[ALARM_ZONES][self._zone.index] + + @property + def available(self) -> bool: + """Sensor availability.""" + return self._zone_object.human_status != AlarmZoneState.UNAVAILABLE + + @property + def native_value(self) -> StateType: + """Sensor value.""" + if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: + return None + + return status.value diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 73c2c7d00c6..dac8bc4123d 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -31,5 +31,22 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "zone_status": { + "state": { + "alarm": "Alarm", + "armed": "Armed", + "open": "Open", + "excluded": "Excluded", + "faulty": "Faulty", + "inhibited": "Inhibited", + "isolated": "Isolated", + "rest": "Rest", + "sabotated": "Sabotated" + } + } + } } } From 117ff21c484db51b80f107940a638fb329cdb941 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 27 Dec 2023 16:54:08 +0100 Subject: [PATCH 799/927] Add significant Change support for number (#105863) --- homeassistant/components/number/const.py | 2 +- .../components/number/significant_change.py | 92 ++++++++++++++++++ .../number/test_significant_change.py | 94 +++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/number/significant_change.py create mode 100644 tests/components/number/test_significant_change.py diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 4107509e01f..55d22c86648 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -78,7 +78,7 @@ __dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class NumberDeviceClass(StrEnum): """Device class for numbers.""" - # NumberDeviceClass should be aligned with NumberDeviceClass + # NumberDeviceClass should be aligned with SensorDeviceClass APPARENT_POWER = "apparent_power" """Apparent power. diff --git a/homeassistant/components/number/significant_change.py b/homeassistant/components/number/significant_change.py new file mode 100644 index 00000000000..11bca6457f1 --- /dev/null +++ b/homeassistant/components/number/significant_change.py @@ -0,0 +1,92 @@ +"""Helper to test significant Number state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, + check_valid_float, +) + +from .const import NumberDeviceClass + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if (device_class := new_attrs.get(ATTR_DEVICE_CLASS)) is None: + return None + + absolute_change: float | None = None + percentage_change: float | None = None + + # special for temperature + if device_class == NumberDeviceClass.TEMPERATURE: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + # special for percentage + elif device_class in ( + NumberDeviceClass.BATTERY, + NumberDeviceClass.HUMIDITY, + NumberDeviceClass.MOISTURE, + ): + absolute_change = 1.0 + + # special for power factor + elif device_class == NumberDeviceClass.POWER_FACTOR: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE: + absolute_change = 1.0 + else: + absolute_change = 0.1 + percentage_change = 2.0 + + # default for all other classified + else: + absolute_change = 1.0 + percentage_change = 2.0 + + if not check_valid_float(new_state): + # New state is invalid, don't report it + return False + + if not check_valid_float(old_state): + # Old state was invalid, we should report again + return True + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) diff --git a/tests/components/number/test_significant_change.py b/tests/components/number/test_significant_change.py new file mode 100644 index 00000000000..1a6491f3de9 --- /dev/null +++ b/tests/components/number/test_significant_change.py @@ -0,0 +1,94 @@ +"""Test the Number significant change platform.""" +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.number.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) + +AQI_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.AQI} +BATTERY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.BATTERY} +CO_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO} +CO2_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO2} +HUMIDITY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.HUMIDITY} +MOISTURE_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.MOISTURE} +PM1_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM1} +PM10_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM10} +PM25_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM25} +POWER_FACTOR_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, +} +POWER_FACTOR_ATTRS_PERCENTAGE = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, +} +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, +} +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, +} +VOLATILE_ORGANIC_COMPOUNDS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS +} + + +@pytest.mark.parametrize( + ("old_state", "new_state", "attrs", "result"), + [ + ("0", "0.9", {}, None), + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("0", "1", CO_ATTRS, True), + ("0.1", "0.5", CO_ATTRS, False), + ("0", "1", CO2_ATTRS, True), + ("0.1", "0.5", CO2_ATTRS, False), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("100", "100", MOISTURE_ATTRS, False), + ("100", "99", MOISTURE_ATTRS, True), + ("0", "1", PM1_ATTRS, True), + ("0.1", "0.5", PM1_ATTRS, False), + ("0", "1", PM10_ATTRS, True), + ("0.1", "0.5", PM10_ATTRS, False), + ("0", "1", PM25_ATTRS, True), + ("0.1", "0.5", PM25_ATTRS, False), + ("0.1", "0.2", POWER_FACTOR_ATTRS, True), + ("0.1", "0.19", POWER_FACTOR_ATTRS, False), + ("1", "2", POWER_FACTOR_ATTRS_PERCENTAGE, True), + ("1", "1.9", POWER_FACTOR_ATTRS_PERCENTAGE, False), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ("fail", "70", TEMP_FREEDOM_ATTRS, True), + ("70", "fail", TEMP_FREEDOM_ATTRS, False), + ("0", "1", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, True), + ("0.1", "0.5", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, False), + ], +) +async def test_significant_change_temperature( + old_state, new_state, attrs, result +) -> None: + """Detect temperature significant changes.""" + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result + ) From 13702d51b1201ae496c0b262549c3e80620ec183 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Dec 2023 16:55:07 +0100 Subject: [PATCH 800/927] Add more fine grained control over Matter server commissioning for the Companion apps (#106237) --- homeassistant/components/matter/api.py | 10 ++++++++-- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../config_entry_diagnostics_redacted.json | 1 - .../fixtures/nodes/device_diagnostics.json | 1 - tests/components/matter/test_api.py | 20 ++++++------------- 7 files changed, 17 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 227d0c73e89..2df21d8f7a2 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -85,6 +85,7 @@ def async_handle_failed_command( { vol.Required(TYPE): "matter/commission", vol.Required("code"): str, + vol.Optional("network_only"): bool, } ) @websocket_api.async_response @@ -97,7 +98,9 @@ async def websocket_commission( matter: MatterAdapter, ) -> None: """Add a device to the network and commission the device.""" - await matter.matter_client.commission_with_code(msg["code"]) + await matter.matter_client.commission_with_code( + msg["code"], network_only=msg.get("network_only", True) + ) connection.send_result(msg[ID]) @@ -106,6 +109,7 @@ async def websocket_commission( { vol.Required(TYPE): "matter/commission_on_network", vol.Required("pin"): int, + vol.Optional("ip_addr"): str, } ) @websocket_api.async_response @@ -118,7 +122,9 @@ async def websocket_commission_on_network( matter: MatterAdapter, ) -> None: """Commission a device already on the network.""" - await matter.matter_client.commission_on_network(msg["pin"]) + await matter.matter_client.commission_on_network( + msg["pin"], ip_addr=msg.get("ip_addr", None) + ) connection.send_result(msg[ID]) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index f350cda9227..848e89660ed 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.0.0"] + "requirements": ["python-matter-server==5.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ecaf2d1e8d1..1a49b477398 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2201,7 +2201,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 653b80daf64..0a78ae734ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1659,7 +1659,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==5.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index c85ee4d70e3..503fd3b9a7a 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -14,7 +14,6 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", - "last_subscription_attempt": 0, "interview_version": 2, "attributes": { "0/4/0": 128, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index d95fbe5efa9..1d1d450e1f0 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -3,7 +3,6 @@ "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", "interview_version": 2, - "last_subscription_attempt": 0, "attributes": { "0/4/0": 128, "0/4/65532": 1, diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 041920f653f..24dac910d33 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -32,7 +32,7 @@ async def test_commission( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", True) matter_client.commission_with_code.reset_mock() matter_client.commission_with_code.side_effect = InvalidCommand( @@ -40,17 +40,13 @@ async def test_commission( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission", - "code": "12345678", - } + {ID: 2, TYPE: "matter/commission", "code": "12345678", "network_only": False} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "9" - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", False) # This tests needs to be adjusted to remove lingering tasks @@ -74,7 +70,7 @@ async def test_commission_on_network( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, None) matter_client.commission_on_network.reset_mock() matter_client.commission_on_network.side_effect = NodeCommissionFailed( @@ -82,17 +78,13 @@ async def test_commission_on_network( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission_on_network", - "pin": 1234, - } + {ID: 2, TYPE: "matter/commission_on_network", "pin": 1234, "ip_addr": "1.2.3.4"} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "1" - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, "1.2.3.4") # This tests needs to be adjusted to remove lingering tasks From e507d1c5a51827d89bc170ed2e7e276cbd374758 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Dec 2023 16:55:42 +0100 Subject: [PATCH 801/927] Add more Withings measurement sensors (#105561) --- homeassistant/components/withings/sensor.py | 27 +++++++++++++++++++ .../components/withings/strings.json | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index c7ff66e9b4d..de053d6a894 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -233,6 +233,33 @@ MEASUREMENT_SENSORS: dict[ translation_key="vascular_age", entity_registry_enabled_default=False, ), + MeasurementType.VISCERAL_FAT: WithingsMeasurementSensorEntityDescription( + key="visceral_fat", + measurement_type=MeasurementType.VISCERAL_FAT, + translation_key="visceral_fat_index", + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_FEET: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_feet", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_FEET, + translation_key="electrodermal_activity_feet", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_left_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT, + translation_key="electrodermal_activity_left_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_right_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT, + translation_key="electrodermal_activity_right_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), } diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index ffbbd9acc2b..a142dd23eac 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -92,6 +92,18 @@ "vascular_age": { "name": "Vascular age" }, + "visceral_fat_index": { + "name": "Visceral fat index" + }, + "electrodermal_activity_feet": { + "name": "Electrodermal activity feet" + }, + "electrodermal_activity_left_foot": { + "name": "Electrodermal activity left foot" + }, + "electrodermal_activity_right_foot": { + "name": "Electrodermal activity right foot" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, From 433045221255f53beb858dd62699e0345132e3d0 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Wed, 27 Dec 2023 19:21:10 +0300 Subject: [PATCH 802/927] Fix Starline attributes timezone (#105645) Co-authored-by: Franck Nijhof --- homeassistant/components/starline/account.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index d2b7e3a4aa1..2940dcf0579 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -25,6 +25,12 @@ from .const import ( ) +def _parse_datetime(dt_str: str | None) -> str | None: + if dt_str is None or (parsed := dt_util.parse_datetime(dt_str)) is None: + return None + return parsed.replace(tzinfo=dt_util.UTC).isoformat() + + class StarlineAccount: """StarLine Account class.""" @@ -143,9 +149,7 @@ class StarlineAccount: def gps_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for device tracker.""" return { - "updated": dt_util.utc_from_timestamp(device.position["ts"]) - .replace(tzinfo=None) - .isoformat(), + "updated": dt_util.utc_from_timestamp(device.position["ts"]).isoformat(), "online": device.online, } @@ -155,7 +159,7 @@ class StarlineAccount: return { "operator": device.balance.get("operator"), "state": device.balance.get("state"), - "updated": device.balance.get("ts"), + "updated": _parse_datetime(device.balance.get("ts")), } @staticmethod From 37707edc478eff9bd123f669effbeff9f317268a Mon Sep 17 00:00:00 2001 From: Caius-Bonus <123886836+Caius-Bonus@users.noreply.github.com> Date: Wed, 27 Dec 2023 17:48:30 +0100 Subject: [PATCH 803/927] Match ZHA Custom ClusterHandler on a Custom Cluster using a unique id for the quirk (#101709) * initial * fix tests * match on specific name and quirk name * fix tests * fix tests * store cluster handlers in only one place * edit tests * use correct device for quirk id * change quirk id * fix tests * even if there is a quirk id, it doesn't have to have a specific cluster handler * add tests * rename quirk_id * add tests * fix tests * fix tests * use quirk id from zha_quirks --- .../cluster_handlers/manufacturerspecific.py | 11 +- .../components/zha/core/decorators.py | 18 +++ .../components/zha/core/discovery.py | 15 +- homeassistant/components/zha/core/endpoint.py | 23 ++- .../components/zha/core/registries.py | 6 +- tests/components/zha/test_cluster_handlers.py | 132 ++++++++++++++++-- 6 files changed, 177 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 99c1e954a0e..556eb907605 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,8 +5,9 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType -from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 import zigpy.zcl +from zigpy.zcl.clusters.closures import DoorLock from homeassistant.core import callback @@ -24,6 +25,7 @@ from ..const import ( UNKNOWN, ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from .general import MultistateInput if TYPE_CHECKING: from ..endpoint import Endpoint @@ -403,3 +405,10 @@ class IkeaRemote(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 +) +class XiaomiVibrationAQ1ClusterHandler(MultistateInput): + """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 71bfd510bea..192f6848989 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -21,6 +21,24 @@ class DictRegistry(dict[int | str, _TypeT]): return decorator +class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): + """Dict Registry of multiple items per key.""" + + def register( + self, name: int | str, sub_name: int | str | None = None + ) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific and a quirk name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + if name not in self: + self[name] = {} + self[name][sub_name] = cluster_handler + return cluster_handler + + return decorator + + class SetRegistry(set[int | str]): """Set Registry of items.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 90ed68f9b00..1944f632e9a 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -203,9 +203,20 @@ class ProbeEndpoint: if platform is None: continue - cluster_handler_class = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} ) + + quirk_id = ( + endpoint.device.quirk_id + if endpoint.device.quirk_id in cluster_handler_classes + else None + ) + + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler + ) + cluster_handler = cluster_handler_class(cluster, endpoint) self.probe_single_cluster(platform, cluster_handler, endpoint) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index c87ee60d6b3..04c253128ee 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -6,7 +6,6 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -import zigpy from zigpy.typing import EndpointType as ZigpyEndpointType from homeassistant.const import Platform @@ -15,7 +14,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const, discovery, registries from .cluster_handlers import ClusterHandler -from .cluster_handlers.general import MultistateInput from .helpers import get_zha_data if TYPE_CHECKING: @@ -116,8 +114,16 @@ class Endpoint: def add_all_cluster_handlers(self) -> None: """Create and add cluster handlers for all input clusters.""" for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ) + quirk_id = ( + self.device.quirk_id + if self.device.quirk_id in cluster_handler_classes + else None + ) + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler ) # Allow cluster handler to filter out bad matches @@ -129,15 +135,6 @@ class Endpoint: cluster_id, cluster_handler_class, ) - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if ( - hasattr(cluster, "ep_attribute") - and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id - and cluster.ep_attribute == "multistate_input" - ): - cluster_handler_class = MultistateInput - # end of ugly hack try: cluster_handler = cluster_handler_class(cluster, self) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 87f59f31e9b..b302116694d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -15,7 +15,7 @@ from zigpy.types.named import EUI64 from homeassistant.const import Platform -from .decorators import DictRegistry, SetRegistry +from .decorators import DictRegistry, NestedDictRegistry, SetRegistry if TYPE_CHECKING: from ..entity import ZhaEntity, ZhaGroupEntity @@ -110,7 +110,9 @@ CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ type[ClientClusterHandler] ] = DictRegistry() -ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[ + type[ClusterHandler] +] = NestedDictRegistry() WEIGHT_ATTR = attrgetter("weight") diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index e3d5741acd8..39f201e668e 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable import logging import math +from types import NoneType from unittest import mock from unittest.mock import AsyncMock, patch @@ -11,12 +12,17 @@ import zigpy.device import zigpy.endpoint from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha +import zigpy.quirks as zigpy_quirks import zigpy.types as t from zigpy.zcl import foundation import zigpy.zcl.clusters +from zigpy.zcl.clusters import CLUSTERS_BY_ID import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.cluster_handlers as cluster_handlers +from homeassistant.components.zha.core.cluster_handlers.lighting import ( + ColorClusterHandler, +) import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint @@ -97,7 +103,9 @@ def poll_control_ch(endpoint, zigpy_device_mock): ) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get(cluster_id) + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id + ).get(None) return cluster_handler_class(cluster, endpoint) @@ -258,8 +266,8 @@ async def test_in_cluster_handler_config( cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None, cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -322,8 +330,8 @@ async def test_out_cluster_handler_config( cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None: cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -334,13 +342,46 @@ async def test_out_cluster_handler_config( def test_cluster_handler_registry() -> None: """Test ZIGBEE cluster handler Registry.""" + + # get all quirk ID from zigpy quirks registry + all_quirk_ids = {} + for cluster_id in CLUSTERS_BY_ID: + all_quirk_ids[cluster_id] = {None} + for manufacturer in zigpy_quirks._DEVICE_REGISTRY.registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, zha_const.ATTR_QUIRK_ID, None) + device_description = getattr(quirk, "replacement", None) or getattr( + quirk, "signature", None + ) + + for endpoint in device_description["endpoints"].values(): + cluster_ids = set() + if "input_clusters" in endpoint: + cluster_ids.update(endpoint["input_clusters"]) + if "output_clusters" in endpoint: + cluster_ids.update(endpoint["output_clusters"]) + for cluster_id in cluster_ids: + if not isinstance(cluster_id, int): + cluster_id = cluster_id.cluster_id + if cluster_id not in all_quirk_ids: + all_quirk_ids[cluster_id] = {None} + all_quirk_ids[cluster_id].add(quirk_id) + + del quirk, model_quirk_list, manufacturer + for ( cluster_id, - cluster_handler, + cluster_handler_classes, ) in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF - assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert cluster_id in all_quirk_ids + assert isinstance(cluster_handler_classes, dict) + for quirk_id, cluster_handler in cluster_handler_classes.items(): + assert isinstance(quirk_id, NoneType) or isinstance(quirk_id, str) + assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert quirk_id in all_quirk_ids[cluster_id] def test_epch_unclaimed_cluster_handlers(cluster_handler) -> None: @@ -818,7 +859,8 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: ], ) - mock_zha_device = mock.AsyncMock(spec_set=ZHADevice) + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) # The cluster handler throws an error when matching this cluster @@ -827,14 +869,84 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: # And one is also logged at runtime with patch.dict( - registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY, - {cluster.cluster_id: TestZigbeeClusterHandler}, + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {None: TestZigbeeClusterHandler}, ), caplog.at_level(logging.WARNING): zha_endpoint.add_all_cluster_handlers() assert "missing_attr" in caplog.text +async def test_standard_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], ColorClusterHandler + ) + + +async def test_quirk_id_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = "__test_quirk_id" + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], TestZigbeeClusterHandler + ) + + # parametrize side effects: @pytest.mark.parametrize( ("side_effect", "expected_error"), From 65c21438a63327ee32aea6b4d80c19d0d11962da Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Wed, 27 Dec 2023 13:58:35 -0300 Subject: [PATCH 804/927] Add query type validation independent of declaration position for SQL (#105921) * Add query type validation independent of declaration position * Restore close sess * Separates invalid query and non-read-only query tests * Add more tests * Use the SQLParseError exception for queries that are not read-only * Add handling for multiple SQL queries. * Fix test * Clean ';' at the beginning of the SQL query * Clean ';' at the beginning of the SQL query - init * Query cleaning before storing * Query cleaning before setup sesensor plataform - YAML * Exception when the SQL query type is not detected * Cleaning * Cleaning * Fix typing in tests * Fix typing in tests * Add test for query = ';;' * Update homeassistant/components/sql/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/sql/__init__.py Co-authored-by: G Johansson * Update __init__.py * Update config_flow.py * Clean query before storing --------- Co-authored-by: G Johansson --- homeassistant/components/sql/__init__.py | 10 +- homeassistant/components/sql/config_flow.py | 35 +++++-- homeassistant/components/sql/manifest.json | 2 +- homeassistant/components/sql/strings.json | 4 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sql/__init__.py | 91 +++++++++++++++- tests/components/sql/test_config_flow.py | 110 ++++++++++++++++++++ tests/components/sql/test_init.py | 26 +++++ tests/components/sql/test_sensor.py | 16 +++ 10 files changed, 286 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 4658e19932c..a4768165c25 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import sqlparse import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -38,9 +39,14 @@ _LOGGER = logging.getLogger(__name__) def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise vol.Invalid("Multiple SQL queries are not supported") + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise vol.Invalid("Invalid SQL query") + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) raise vol.Invalid("Only SELECT queries allowed") - return value + return str(query[0]) QUERY_SCHEMA = vol.Schema( diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index e00b1f8e402..a697bdc51a7 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,8 +6,10 @@ from typing import Any import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError +from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +import sqlparse +from sqlparse.exceptions import SQLParseError import voluptuous as vol from homeassistant import config_entries @@ -80,11 +82,16 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( ).extend(OPTIONS_SCHEMA.schema) -def validate_sql_select(value: str) -> str | None: +def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): - raise ValueError("Incorrect Query") - return value + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise MultipleResultsFound + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise ValueError + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) + raise SQLParseError + return str(query[0]) def validate_query(db_url: str, query: str, column: str) -> bool: @@ -148,7 +155,7 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): db_url_for_validation = None try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -156,9 +163,14 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" options = { @@ -209,7 +221,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): name = self.options.get(CONF_NAME, self.config_entry.title) try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -217,9 +229,14 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" else: recorder_db = get_instance(self.hass).db_url diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c63ba19e0ad..5ebd79b09a5 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.23"] + "requirements": ["SQLAlchemy==2.0.23", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index b4bb73d4b99..361585b8876 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -6,6 +6,8 @@ "error": { "db_url_invalid": "Database URL invalid", "query_invalid": "SQL Query invalid", + "query_no_read_only": "SQL query must be read-only", + "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" }, "step": { @@ -61,6 +63,8 @@ "error": { "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", "query_invalid": "[%key:component::sql::config::error::query_invalid%]", + "query_no_read_only": "[%key:component::sql::config::error::query_no_read_only%]", + "multiple_queries": "[%key:component::sql::config::error::multiple_queries%]", "column_invalid": "[%key:component::sql::config::error::column_invalid%]" } }, diff --git a/requirements_all.txt b/requirements_all.txt index 1a49b477398..7d97a3f1604 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2535,6 +2535,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a78ae734ee..2b5cecac4c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1909,6 +1909,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 6a629f9603d..9cdd026bd3b 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -46,17 +46,104 @@ ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { ENTRY_CONFIG_INVALID_QUERY = { CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2 = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_3 = { + CONF_NAME: "Get Value", + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_OPT = { + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2_OPT = { + CONF_QUERY: "SELECT5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_3_OPT = { + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY = { + CONF_NAME: "Get Value", + CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } -ENTRY_CONFIG_INVALID_QUERY_OPT = { - CONF_QUERY: "UPDATE 5 as value", +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } + +ENTRY_CONFIG_MULTIPLE_QUERIES = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + ENTRY_CONFIG_INVALID_COLUMN_NAME = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 6517e319fe4..43608d0d32a 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -17,8 +17,18 @@ from . import ( ENTRY_CONFIG_INVALID_COLUMN_NAME, ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ENTRY_CONFIG_INVALID_QUERY, + ENTRY_CONFIG_INVALID_QUERY_2, + ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ENTRY_CONFIG_INVALID_QUERY_3, + ENTRY_CONFIG_INVALID_QUERY_3_OPT, ENTRY_CONFIG_INVALID_QUERY_OPT, + ENTRY_CONFIG_MULTIPLE_QUERIES, + ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ENTRY_CONFIG_NO_RESULTS, + ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ENTRY_CONFIG_WITH_VALUE_TEMPLATE, ) @@ -132,6 +142,56 @@ async def test_flow_fails_invalid_query( "query": "query_invalid", } + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ) + + assert result5["type"] == FlowResultType.FORM + assert result5["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "multiple_queries", + } + result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, @@ -380,6 +440,56 @@ async def test_options_flow_fails_invalid_query( "query": "query_invalid", } + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "multiple_queries", + } + result4 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 50de8aba7b3..2ae6010e0c5 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -58,6 +58,32 @@ async def test_invalid_query(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): validate_sql_select("DROP TABLE *") + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT5 as value") + + with pytest.raises(vol.Invalid): + validate_sql_select(";;") + + +async def test_query_no_read_only(hass: HomeAssistant) -> None: + """Test query no read only.""" + with pytest.raises(vol.Invalid): + validate_sql_select("UPDATE states SET state = 999999 WHERE state_id = 11125") + + +async def test_query_no_read_only_cte(hass: HomeAssistant) -> None: + """Test query no read only CTE.""" + with pytest.raises(vol.Invalid): + validate_sql_select( + "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;" + ) + + +async def test_multiple_queries(hass: HomeAssistant) -> None: + """Test multiple queries.""" + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") + async def test_remove_configured_db_url_if_not_needed_when_not_needed( recorder_mock: Recorder, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cdc9a8e07a6..9ac22f48312 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -57,6 +57,22 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None assert state.attributes["value"] == 5 +async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test the SQL sensor with CTE.""" + config = { + "db_url": "sqlite://", + "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + "column": "state", + "name": "Select value SQL query CTE", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + + state = hass.states.get("sensor.select_value_sql_query_cte") + assert state.state == "10" + assert state.attributes["state"] == 10 + + async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: From eb437afc67bacb70b8bd444fce44b82da6530837 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 27 Dec 2023 17:59:52 +0100 Subject: [PATCH 805/927] Update frontend to 20231227.0 (#106486) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2a7ef1396d5..9c631b4cfd5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231208.2"] + "requirements": ["home-assistant-frontend==20231227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40ead87a5bc..7e99b45fca2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.0 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20231208.2 +home-assistant-frontend==20231227.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7d97a3f1604..b70926bce53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.38 # homeassistant.components.frontend -home-assistant-frontend==20231208.2 +home-assistant-frontend==20231227.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b5cecac4c8..e8a0f6f85f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -828,7 +828,7 @@ hole==0.8.0 holidays==0.38 # homeassistant.components.frontend -home-assistant-frontend==20231208.2 +home-assistant-frontend==20231227.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 9508a23f95e410393e36fbecbb88dd650fd5ae09 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 27 Dec 2023 18:01:44 +0100 Subject: [PATCH 806/927] Check and register cloud hook if needed for mobile_app (#106461) --- .../components/mobile_app/__init__.py | 22 +++ tests/components/mobile_app/test_init.py | 127 +++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 3d33af38761..94d268f9412 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -103,6 +103,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) + async def create_cloud_hook() -> None: + """Create a cloud hook.""" + hook = await cloud.async_create_cloudhook(hass, webhook_id) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook} + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if ( + state is cloud.CloudConnectionState.CLOUD_CONNECTED + and CONF_CLOUDHOOK_URL not in entry.data + ): + await create_cloud_hook() + + if ( + CONF_CLOUDHOOK_URL not in registration + and cloud.async_active_subscription(hass) + and cloud.async_is_connected(hass) + ): + await create_cloud_hook() + entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass_notify.async_reload(hass, DOMAIN) diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 59f2a130737..d504703c222 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -1,16 +1,34 @@ """Tests for the mobile app integration.""" -from homeassistant.components.mobile_app.const import DATA_DELETED_IDS, DOMAIN +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.mobile_app.const import ( + ATTR_DEVICE_NAME, + CONF_CLOUDHOOK_URL, + CONF_USER_ID, + DATA_DELETED_IDS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import CALL_SERVICE +from .const import CALL_SERVICE, REGISTER_CLEARTEXT -from tests.common import async_mock_service +from tests.common import ( + MockConfigEntry, + MockUser, + async_mock_cloud_connection_status, + async_mock_service, +) -async def test_unload_unloads( - hass: HomeAssistant, create_registrations, webhook_client -) -> None: +@pytest.mark.usefixtures("create_registrations") +async def test_unload_unloads(hass: HomeAssistant, webhook_client) -> None: """Test we clean up when we unload.""" # Second config entry is the one without encryption config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -28,11 +46,11 @@ async def test_unload_unloads( assert len(calls) == 1 +@pytest.mark.usefixtures("create_registrations") async def test_remove_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - create_registrations, ) -> None: """Test we clean up when we remove entry.""" for config_entry in hass.config_entries.async_entries("mobile_app"): @@ -41,3 +59,98 @@ async def test_remove_entry( assert len(device_registry.devices) == 0 assert len(entity_registry.entities) == 0 + + +async def _test_create_cloud_hook( + hass: HomeAssistant, + hass_admin_user: MockUser, + additional_config: dict[str, Any], + async_active_subscription_return_value: bool, + additional_steps: Callable[[ConfigEntry, Mock, str], Awaitable[None]], +) -> None: + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: "test-webhook-id", + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + **additional_config, + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=async_active_subscription_return_value, + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", autospec=True + ) as mock_create_cloudhook: + cloud_hook = "https://hook-url" + mock_create_cloudhook.return_value = cloud_hook + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + await additional_steps(config_entry, mock_create_cloudhook, cloud_hook) + + +async def test_create_cloud_hook_on_setup( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook during setup.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) + + await _test_create_cloud_hook(hass, hass_admin_user, {}, True, additional_steps) + + +async def test_create_cloud_hook_aleady_exists( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook is not called, when a cloud hook already exists.""" + cloud_hook = "https://hook-url-already-exists" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, _: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_not_called() + + await _test_create_cloud_hook( + hass, hass_admin_user, {CONF_CLOUDHOOK_URL: cloud_hook}, True, additional_steps + ) + + +async def test_create_cloud_hook_after_connection( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook when connected to the cloud.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert CONF_CLOUDHOOK_URL not in config_entry.data + mock_create_cloudhook.assert_not_called() + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) + + await _test_create_cloud_hook(hass, hass_admin_user, {}, False, additional_steps) From 389c8d39f52f9f81fb410fab68cbf9c528e76926 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 27 Dec 2023 18:28:24 +0100 Subject: [PATCH 807/927] Add significant Change support for cover (#106016) --- .../components/cover/significant_change.py | 55 ++++++++++++++++ .../cover/test_significant_change.py | 65 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 homeassistant/components/cover/significant_change.py create mode 100644 tests/components/cover/test_significant_change.py diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py new file mode 100644 index 00000000000..8762af496c8 --- /dev/null +++ b/homeassistant/components/cover/significant_change.py @@ -0,0 +1,55 @@ +"""Helper to test significant Cover state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/cover/test_significant_change.py b/tests/components/cover/test_significant_change.py new file mode 100644 index 00000000000..9ddb2cb9498 --- /dev/null +++ b/tests/components/cover/test_significant_change.py @@ -0,0 +1,65 @@ +"""Test the Cover significant change platform.""" +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +) +from homeassistant.components.cover.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Cover significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # float attributes + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 61.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 60.9}, False), + ({ATTR_CURRENT_POSITION: "invalid"}, {ATTR_CURRENT_POSITION: 60.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: "invalid"}, False), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 61.0}, True), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 60.9}, False), + # multiple attributes + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 61, + }, + True, + ), + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 59.1, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60.9, + }, + True, + ), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Cover significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 093c952c38fb25e9046b36d219c182479c376ccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Dec 2023 08:04:07 -1000 Subject: [PATCH 808/927] Bump aiohttp-zlib-ng to 0.1.3 (#106489) Reverts the workaround that was created for #105254 since the original issue is fixed in zlib_ng 0.3.0+ which the lib now requires as a minimum version --- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 87b6d5c3902..399cbf70ad7 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.2" + "aiohttp-zlib-ng==0.1.3" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e99b45fca2..18a8b14b9d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.2 +aiohttp-zlib-ng==0.1.3 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 2f06e566bc3..3371ec81146 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.2", + "aiohttp-zlib-ng==0.1.3", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index b14ae118d6c..2cac92b4972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.2 +aiohttp-zlib-ng==0.1.3 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index b70926bce53..6c1c0173c91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.0 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.2 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8a0f6f85f2..b3ca6d316ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.0 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.2 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http From 9b2c67fcd207673efddeb2162503e8d053c95662 Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Wed, 27 Dec 2023 11:56:54 -0800 Subject: [PATCH 809/927] Add Color Night Vision switch for UniFi Protect (#106500) * Add a switch to enable and disable "Color Night Vision" for the Unifi Protect platform, which is a feature on the new G5 Pro cameras with a "Vision Enhancer" attached * Updated tests for the new switch --- homeassistant/components/unifiprotect/switch.py | 10 ++++++++++ tests/components/unifiprotect/test_switch.py | 1 + 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index e4cb4b7ff46..c57546be8d0 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -209,6 +209,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_smoke_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="color_night_vision", + name="Color Night Vision", + icon="mdi:light-flood-down", + entity_category=EntityCategory.CONFIG, + ufp_required_field="has_color_night_vision", + ufp_value="isp_settings.is_color_night_vision_enabled", + ufp_set_method="set_color_night_vision", + ufp_perm=PermRequired.WRITE, + ), ) PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index b8932b99e2c..17db53d05ec 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -38,6 +38,7 @@ CAMERA_SWITCHES_BASIC = [ and d.name != "Detections: License Plate" and d.name != "Detections: Smoke/CO" and d.name != "SSH Enabled" + and d.name != "Color Night Vision" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC if d.name not in ("High FPS", "Privacy Mode") From c462d5b8ca27ee5f8cbb2c5d0561bf45c7021bc8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 27 Dec 2023 21:05:26 +0100 Subject: [PATCH 810/927] Bump zwave-js-server-python to 0.55.2 (#106496) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 62e1ecfaf08..9a66dae8e93 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.2"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 6c1c0173c91..31a7b55ceea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2893,7 +2893,7 @@ zigpy==0.60.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.1 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3ca6d316ab..32ff3d43dcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2186,7 +2186,7 @@ zigpy-znp==0.12.1 zigpy==0.60.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.1 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 11e4dd0764e84c7b61be97960bd8d93d03f964b3 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 27 Dec 2023 15:15:45 -0500 Subject: [PATCH 811/927] Bump ZHA quirks to 109 and add associated configuration entities (#106492) --- .../cluster_handlers/manufacturerspecific.py | 11 ++++ homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/select.py | 62 +++++++++++++++++++ homeassistant/components/zha/strings.json | 9 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 556eb907605..57f1e2ee304 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -149,6 +149,17 @@ class OppleRemote(ClusterHandler): "buzzer": True, "linkage_alarm": True, } + elif self.cluster.endpoint.model == "lumi.magnet.ac01": + self.ZCL_INIT_ATTRS = { + "detection_distance": True, + } + elif self.cluster.endpoint.model == "lumi.switch.acn047": + self.ZCL_INIT_ATTRS = { + "switch_mode": True, + "switch_type": True, + "startup_on_off": True, + "decoupled_mode": True, + } async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Initialize cluster handler specific.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a2965e782f4..6a14a3064a6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.37.4", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.108", + "zha-quirks==0.0.109", "zigpy-deconz==0.22.3", "zigpy==0.60.2", "zigpy-xbee==0.20.1", diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 2ff8b7d36b9..1c13779209d 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -7,6 +7,8 @@ import logging from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF +from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster +from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -408,6 +410,66 @@ class AqaraApproachDistance(ZCLEnumSelectEntity): _attr_translation_key: str = "approach_distance" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.magnet.ac01"} +) +class AqaraMagnetAC01DetectionDistance(ZCLEnumSelectEntity): + """Representation of a ZHA detection distance configuration entity.""" + + _unique_id_suffix = "detection_distance" + _attribute_name = "detection_distance" + _enum = MagnetAC01OppleCluster.DetectionDistance + _attr_translation_key: str = "detection_distance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch mode configuration entity.""" + + _unique_id_suffix = "switch_mode" + _attribute_name = "switch_mode" + _enum = T2RelayOppleCluster.SwitchMode + _attr_translation_key: str = "switch_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchType(ZCLEnumSelectEntity): + """Representation of a ZHA switch type configuration entity.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = T2RelayOppleCluster.SwitchType + _attr_translation_key: str = "switch_type" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayStartupOnOff(ZCLEnumSelectEntity): + """Representation of a ZHA startup on off configuration entity.""" + + _unique_id_suffix = "startup_on_off" + _attribute_name = "startup_on_off" + _enum = T2RelayOppleCluster.StartupOnOff + _attr_translation_key: str = "start_up_on_off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch decoupled mode configuration entity.""" + + _unique_id_suffix = "decoupled_mode" + _attribute_name = "decoupled_mode" + _enum = T2RelayOppleCluster.DecoupledMode + _attr_translation_key: str = "decoupled_mode" + + class AqaraE1ReverseDirection(types.enum8): """Aqara curtain reversal.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 18bb3ae4f82..00d24fbc82e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -780,6 +780,15 @@ }, "preset": { "name": "Preset" + }, + "detection_distance": { + "name": "Detection distance" + }, + "switch_mode": { + "name": "Switch mode" + }, + "decoupled_mode": { + "name": "Decoupled mode" } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 31a7b55ceea..ef621c9ce94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2866,7 +2866,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.108 +zha-quirks==0.0.109 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32ff3d43dcf..3e3256385fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2168,7 +2168,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.108 +zha-quirks==0.0.109 # homeassistant.components.zha zigpy-deconz==0.22.3 From d0409e719b707355ff6ad434cae44a291782b077 Mon Sep 17 00:00:00 2001 From: theorlangur Date: Wed, 27 Dec 2023 21:16:03 +0100 Subject: [PATCH 812/927] Add ZHA configuration number entity to set/get thermostat cluster local temperature offset (#105765) --- .../zha/core/cluster_handlers/hvac.py | 1 + homeassistant/components/zha/number.py | 19 +++++++++++++++++++ homeassistant/components/zha/strings.json | 3 +++ 3 files changed, 23 insertions(+) diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index dad3ee5eb4d..5e41785a6d8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -110,6 +110,7 @@ class ThermostatClusterHandler(ClusterHandler): "max_heat_setpoint_limit": True, "min_cool_setpoint_limit": True, "min_heat_setpoint_limit": True, + "local_temperature_calibration": True, } @property diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 53d79d2d35f..24964d7a154 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,6 +20,7 @@ from .core.const import ( CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_THERMOSTAT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -947,3 +948,21 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): + """Local temperature calibration.""" + + _unique_id_suffix = "local_temperature_calibration" + _attr_native_min_value: float = -2.5 + _attr_native_max_value: float = 2.5 + _attr_native_step: float = 0.1 + _attr_multiplier: float = 0.1 + _attribute_name = "local_temperature_calibration" + _attr_translation_key: str = "local_temperature_calibration" + + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 00d24fbc82e..8909af8a5ba 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -724,6 +724,9 @@ }, "quick_start_time": { "name": "Quick start time" + }, + "local_temperature_calibration": { + "name": "Local temperature offset" } }, "select": { From 55458834001d4f6a51632fb17c03362a4ae7e7d5 Mon Sep 17 00:00:00 2001 From: Bernardus Jansen Date: Wed, 27 Dec 2023 21:16:55 +0100 Subject: [PATCH 813/927] Opentherm gateway: Set unit of measurement for count sensors (#106313) --- homeassistant/components/opentherm_gw/const.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index a6c75c17113..7dc2d206912 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -409,25 +409,25 @@ SENSOR_INFO: dict[str, list] = { ], gw_vars.DATA_TOTAL_BURNER_STARTS: [ None, - None, + "starts", "Total Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_STARTS: [ None, - None, + "starts", "Central Heating Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_STARTS: [ None, - None, + "starts", "Hot Water Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_STARTS: [ None, - None, + "starts", "Hot Water Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], From 8778763a3ee2a3e4cbb4bf711e43d9849fd0582a Mon Sep 17 00:00:00 2001 From: Daniel Schall Date: Wed, 27 Dec 2023 12:19:25 -0800 Subject: [PATCH 814/927] Synchronize and cache Generic Camera still image fetching (#105821) --- homeassistant/components/generic/camera.py | 53 ++++++++++---- tests/components/generic/test_camera.py | 83 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 9ffd873efd6..f4c02a2ab9f 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,9 @@ """Support for IP Cameras.""" from __future__ import annotations +import asyncio from collections.abc import Mapping +from datetime import datetime, timedelta import logging from typing import Any @@ -129,6 +131,8 @@ class GenericCamera(Camera): """A generic implementation of an IP camera.""" _last_image: bytes | None + _last_update: datetime + _update_lock: asyncio.Lock def __init__( self, @@ -172,6 +176,8 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + self._last_update = datetime.min + self._update_lock = asyncio.Lock() self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, @@ -198,22 +204,39 @@ class GenericCamera(Camera): if url == self._last_url and self._limit_refetch: return self._last_image - try: - async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) - response = await async_client.get( - url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT - ) - response.raise_for_status() - self._last_image = response.content - except httpx.TimeoutException: - _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image - except (httpx.RequestError, httpx.HTTPStatusError) as err: - _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_image + async with self._update_lock: + if ( + self._last_image is not None + and url == self._last_url + and self._last_update + timedelta(0, self._attr_frame_interval) + > datetime.now() + ): + return self._last_image - self._last_url = url - return self._last_image + try: + update_time = datetime.now() + async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) + response = await async_client.get( + url, + auth=self._auth, + follow_redirects=True, + timeout=GET_IMAGE_TIMEOUT, + ) + response.raise_for_status() + self._last_image = response.content + self._last_update = update_time + + except httpx.TimeoutException: + _LOGGER.error("Timeout getting camera image from %s", self._name) + return self._last_image + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) + return self._last_image + + self._last_url = url + return self._last_image @property def name(self): diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 8bfd0a66dd5..70746f70c9a 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,9 +1,11 @@ """The tests for generic camera component.""" import asyncio +from datetime import timedelta from http import HTTPStatus from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx @@ -49,6 +51,7 @@ async def test_fetching_url( "username": "user", "password": "pass", "authentication": "basic", + "framerate": 20, } }, ) @@ -63,10 +66,87 @@ async def test_fetching_url( body = await resp.read() assert body == fakeimgbytes_png + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 +@respx.mock +async def test_image_caching( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + fakeimgbytes_png, +) -> None: + """Test that the image is cached and not fetched more often than the framerate indicates.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + + framerate = 5 + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # time is frozen, image should have come from cache + assert respx.calls.call_count == 1 + + # advance time by 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Only 150ms have passed, image should still have come from cache + assert respx.calls.call_count == 1 + + # advance time by another 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # 300ms have passed, now we should have fetched a new image + assert respx.calls.call_count == 2 + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Still only 300ms have passed, should have returned the cached image + assert respx.calls.call_count == 2 + + @respx.mock async def test_fetching_without_verify_ssl( hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png @@ -468,6 +548,7 @@ async def test_timeout_cancelled( "still_image_url": "http://example.com", "username": "user", "password": "pass", + "framerate": 20, } }, ) @@ -497,6 +578,8 @@ async def test_timeout_cancelled( ] for total_calls in range(2, 4): + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK From ee25cb2616a79fd7cf08689e06c925753cda452e Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Wed, 27 Dec 2023 21:20:03 +0100 Subject: [PATCH 815/927] Add AsusWrt temperature sensors provided by http protocol (#105692) --- homeassistant/components/asuswrt/bridge.py | 3 +- homeassistant/components/asuswrt/const.py | 3 +- homeassistant/components/asuswrt/sensor.py | 20 +++++++++++ homeassistant/components/asuswrt/strings.json | 6 ++++ tests/components/asuswrt/conftest.py | 12 +++---- tests/components/asuswrt/test_sensor.py | 34 +++++++++++++------ 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 228da7f1a36..53a0b5d06b5 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -41,6 +41,7 @@ from .const import ( SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) SENSORS_TYPE_BYTES = "sensors_bytes" @@ -277,7 +278,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge): async def _get_available_temperature_sensors(self) -> list[str]: """Check which temperature information is available on the router.""" availability = await self._api.async_find_temperature_commands() - return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] + return [SENSORS_TEMPERATURES_LEGACY[i] for i in range(3) if availability[i]] @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES) async def _get_bytes(self) -> Any: diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index a4cd6cde94c..a60046b50c2 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -30,4 +30,5 @@ SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"] SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] -SENSORS_TEMPERATURES = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES_LEGACY = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES = [*SENSORS_TEMPERATURES_LEGACY, "5.0GHz_2", "6.0GHz"] diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 72d99c60816..f1296befbba 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -156,6 +156,26 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, suggested_display_precision=1, ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[3], + translation_key="5ghz_2_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[4], + translation_key="6ghz_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 8a3207ec7cb..4c8386dcd00 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -82,6 +82,12 @@ }, "cpu_temperature": { "name": "CPU Temperature" + }, + "5ghz_2_temperature": { + "name": "5GHz Temperature (Radio 2)" + }, + "6ghz_temperature": { + "name": "6GHz Temperature" } } }, diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 72cbc37d571..7710e26707c 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -20,8 +20,8 @@ MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) -MOCK_TEMPERATURES_HTTP = {"2.4GHz": 40.2, "CPU": 71.2} -MOCK_TEMPERATURES = {**MOCK_TEMPERATURES_HTTP, "5.0GHz": 0} +MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} +MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} @pytest.fixture(name="patch_setup_entry") @@ -118,9 +118,9 @@ def mock_controller_connect_http(mock_devices_http): MOCK_CURRENT_TRANSFER_RATES_HTTP ) service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP - service_mock.return_value.async_get_temperatures.return_value = ( - MOCK_TEMPERATURES_HTTP - ) + service_mock.return_value.async_get_temperatures.return_value = { + k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" + } yield service_mock @@ -140,6 +140,6 @@ def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" with patch( f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", - return_value=[*MOCK_TEMPERATURES], + return_value=[*MOCK_TEMPERATURES_HTTP], ) as mock_sens_detect: yield mock_sens_detect diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index a7b19bb3785..e3122f1dfef 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.asuswrt.const import ( SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState @@ -39,7 +40,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] -SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES_LEGACY] SENSORS_ALL_HTTP = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] @@ -242,11 +243,13 @@ async def test_temperature_sensors_http_fail( assert not hass.states.get(f"{sensor_prefix}_2_4ghz") assert not hass.states.get(f"{sensor_prefix}_5_0ghz") assert not hass.states.get(f"{sensor_prefix}_cpu") + assert not hass.states.get(f"{sensor_prefix}_5_0ghz_2") + assert not hass.states.get(f"{sensor_prefix}_6_0ghz") -async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: +async def _test_temperature_sensors(hass: HomeAssistant, config, sensors) -> str: """Test creating a AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_TEMPERATURES) + config_entry, sensor_prefix = _setup_entry(hass, config, sensors) config_entry.add_to_hass(hass) # initial devices setup @@ -255,20 +258,31 @@ async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" - assert not hass.states.get(f"{sensor_prefix}_5_0ghz") - assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + return sensor_prefix async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: """Test creating a AsusWRT temperature sensors.""" - await _test_temperature_sensors(hass, CONFIG_DATA_TELNET) + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES_LEGACY + ) + # assert temperature sensor available + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: """Test creating a AsusWRT temperature sensors.""" - await _test_temperature_sensors(hass, CONFIG_DATA_HTTP) + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES + ) + # assert temperature sensor available + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + assert hass.states.get(f"{sensor_prefix}_5_0ghz_2").state == "40.3" + assert hass.states.get(f"{sensor_prefix}_6_0ghz").state == "40.4" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") @pytest.mark.parametrize( @@ -416,7 +430,7 @@ async def test_decorator_errors( hass: HomeAssistant, connect_legacy, mock_available_temps ) -> None: """Test AsusWRT sensors are unavailable on decorator type check error.""" - sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES] + sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES_LEGACY] config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_TELNET, sensors) config_entry.add_to_hass(hass) From 0bb892588ef4019d1b412734fd15042cb5131834 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Dec 2023 21:23:25 +0100 Subject: [PATCH 816/927] Bump version to 2024.1.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f6d479aeb42..572aa5d743d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 3371ec81146..33b01c3c288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0.dev0" +version = "2024.1.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 8e4fade725cda6b3de697a1b17e969829993bf98 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 28 Dec 2023 13:56:40 -0500 Subject: [PATCH 817/927] Move services to entity services in blink (#105413) * Use device name to lookup camera * Fix device registry serial * Move to entity based services * Update tests * Use config_entry Move refresh service out of camera * Use config entry for services * Fix service schema * Add depreciation note * Depreciation note * key error changes deprecated (not depreciated) repair issue * tweak message * deprication v2 * back out update field change * backout update schema changes * Finish rollback on update service * update doc strings * move to 2024.7.0 More verbosity to deprecation message --- homeassistant/components/blink/camera.py | 61 +++- homeassistant/components/blink/const.py | 1 + homeassistant/components/blink/services.py | 124 ++------ homeassistant/components/blink/services.yaml | 42 +-- homeassistant/components/blink/strings.json | 50 ++- tests/components/blink/test_services.py | 318 +++++-------------- 6 files changed, 209 insertions(+), 387 deletions(-) diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index f507364f17f..4d05aea88a5 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -8,17 +8,26 @@ import logging from typing import Any from requests.exceptions import ChunkedEncodingError +import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .const import ( + DEFAULT_BRAND, + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_TRIGGER, +) from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,6 +52,16 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") + platform.async_register_entity_service( + SERVICE_SAVE_RECENT_CLIPS, + {vol.Required(CONF_FILE_PATH): cv.string}, + "save_recent_clips", + ) + platform.async_register_entity_service( + SERVICE_SAVE_VIDEO, + {vol.Required(CONF_FILENAME): cv.string}, + "save_video", + ) class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): @@ -64,7 +83,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): manufacturer=DEFAULT_BRAND, model=camera.camera_type, ) - _LOGGER.debug("Initialized blink camera %s", self.name) + _LOGGER.debug("Initialized blink camera %s", self._camera.name) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -121,3 +140,39 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): except TypeError: _LOGGER.debug("No cached image for %s", self._camera.name) return None + + async def save_recent_clips(self, file_path) -> None: + """Save multiple recent clips to output directory.""" + if not self.hass.config.is_allowed_path(file_path): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": file_path}, + ) + + try: + await self._camera.save_recent_clips(output_dir=file_path) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def save_video(self, filename) -> None: + """Handle save video service calls.""" + if not self.hass.config.is_allowed_path(filename): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": filename}, + ) + + try: + await self._camera.video_to_file(filename) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d394b5c0008..7aa3d0d388e 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -24,6 +24,7 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index dae2f0ad951..5c034cdb7c5 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -4,25 +4,16 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr - -from .const import ( - DOMAIN, - SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, - SERVICE_SEND_PIN, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, ) + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, SERVICE_SEND_PIN from .coordinator import BlinkUpdateCoordinator SERVICE_UPDATE_SCHEMA = vol.Schema( @@ -30,26 +21,12 @@ SERVICE_UPDATE_SCHEMA = vol.Schema( vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), } ) -SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILENAME): cv.string, - } -) SERVICE_SEND_PIN_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_PIN): cv.string, } ) -SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILE_PATH): cv.string, - } -) def setup_services(hass: HomeAssistant) -> None: @@ -94,57 +71,22 @@ def setup_services(hass: HomeAssistant) -> None: coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators - async def async_handle_save_video_service(call: ServiceCall) -> None: - """Handle save video service calls.""" - camera_name = call.data[CONF_NAME] - video_path = call.data[CONF_FILENAME] - if not hass.config.is_allowed_path(video_path): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": video_path}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].video_to_file(video_path) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - - async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: - """Save multiple recent clips to output directory.""" - camera_name = call.data[CONF_NAME] - clips_dir = call.data[CONF_FILE_PATH] - if not hass.config.is_allowed_path(clips_dir): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": clips_dir}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].save_recent_clips( - output_dir=clips_dir - ) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]: + if not (config_entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinator = hass.data[DOMAIN][entry_id] await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -152,22 +94,24 @@ def setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services + # Refresh service is deprecated and will be removed in 7/2024 service_mapping = [ (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), - ( - async_handle_save_video_service, - SERVICE_SAVE_VIDEO, - SERVICE_SAVE_VIDEO_SCHEMA, - ), - ( - async_handle_save_recent_clips_service, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_RECENT_CLIPS_SCHEMA, - ), (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), ] diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index aaecde64353..87083a990ef 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -9,25 +9,17 @@ blink_update: integration: blink trigger_camera: - fields: - device_id: - required: true - selector: - device: - integration: blink + target: + entity: + integration: blink + domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: filename: required: true example: "/tmp/video.mp4" @@ -35,17 +27,11 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: file_path: required: true example: "/tmp" @@ -54,10 +40,10 @@ save_recent_clips: send_pin: fields: - device_id: + config_entry_id: required: true selector: - device: + config_entry: integration: blink pin: example: "abc123" diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index fc0450dc8ea..87e2fc68c20 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -67,29 +67,15 @@ }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image.", - "fields": { - "device_id": { - "name": "Device ID", - "description": "The Blink device id." - } - } + "description": "Requests camera to take new image." }, "save_video": { "name": "Save video", "description": "Saves last recorded video clip to local file.", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab video from." - }, "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -97,17 +83,9 @@ "name": "Save recent clips", "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab recent clips from." - }, "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -119,19 +97,16 @@ "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." + "config_entry_id": { + "name": "Integration ID", + "description": "The Blink Integration id." } } } }, "exceptions": { - "invalid_device": { - "message": "Device '{target}' is not a {domain} device" - }, - "device_not_found": { - "message": "Device '{target}' not found in device registry" + "integration_not_found": { + "message": "Integraion '{target}' not found in registry" }, "no_path": { "message": "Can't write to directory {target}, no access to path!" @@ -142,5 +117,18 @@ "not_loaded": { "message": "{target} is not loaded" } + }, + "issues": { + "service_deprecation": { + "title": "Blink update service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::blink::issues::service_deprecation::title%]", + "description": "Blink update service is deprecated and will be removed.\nPlease update your automations and scripts to use `Home Assistant Core Integration: Update entity`." + } + } + } + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index ccc326dac1f..1c2faa32d04 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,22 +4,15 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from homeassistant.components.blink.const import ( + ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -43,7 +36,6 @@ async def test_refresh_service_calls( await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - assert device_entry assert mock_config_entry.state is ConfigEntryState.LOADED @@ -67,163 +59,8 @@ async def test_refresh_service_calls( ) -async def test_video_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test video service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - caplog.clear() - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=False) - - -async def test_picture_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test picture servcie calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() - - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( - side_effect=OSError - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - async def test_pin_service_calls( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, @@ -234,17 +71,13 @@ async def test_pin_service_calls( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: [device_entry.id], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, blocking=True, ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once @@ -253,41 +86,18 @@ async def test_pin_service_calls( await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: ["bad-device_id"], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: ["bad-config_id"], CONF_PIN: PIN}, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_non_blink_device( +async def test_service_pin_called_with_non_blink_device( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with non blink device.""" + """Test pin service calls with non blink device.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -295,11 +105,48 @@ async def test_service_called_with_non_blink_device( other_domain = "NotBlink" other_config_id = "555" - await hass.config_entries.async_add( - MockConfigEntry( - title="Not Blink", domain=other_domain, entry_id=other_config_id - ) + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id ) + await hass.config_entries.async_add(other_mock_config_entry) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = { + ATTR_CONFIG_ENTRY_ID: [other_mock_config_entry.entry_id], + CONF_PIN: PIN, + } + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_non_blink_device( + hass: HomeAssistant, + mock_blink_api: MagicMock, + device_registry: dr.DeviceRegistry, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update service calls with non blink device.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + other_domain = "NotBlink" + other_config_id = "555" + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + await hass.config_entries.async_add(other_mock_config_entry) + device_entry = device_registry.async_get_or_create( config_entry_id=other_config_id, identifiers={ @@ -311,67 +158,68 @@ async def test_service_called_with_non_blink_device( mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) - with pytest.raises(ServiceValidationError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_unloaded_entry( +async def test_service_pin_called_with_unloaded_entry( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls with not ready config entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_config_entry.state = ConfigEntryState.SETUP_ERROR + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_unloaded_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with unloaded config entry.""" + """Test update service calls with not ready config entry.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - await mock_config_entry.async_unload(hass) - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry + mock_config_entry.state = ConfigEntryState.SETUP_ERROR hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + assert device_entry + parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) From 924e47c2a85730ed0abb21b83833cf98eaf94d93 Mon Sep 17 00:00:00 2001 From: Bart Janssens Date: Thu, 28 Dec 2023 09:31:35 +0100 Subject: [PATCH 818/927] Skip activating/deactivating Vicare standby preset (#106476) --- homeassistant/components/vicare/climate.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c14f940ffe6..0b8e3cab865 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -311,8 +311,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) _LOGGER.debug("Current preset %s", self._current_program) - if self._current_program and self._current_program != VICARE_PROGRAM_NORMAL: - # We can't deactivate "normal" + if self._current_program and self._current_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_STANDBY, + ]: + # We can't deactivate "normal" or "standby" _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -326,8 +329,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) from err _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) - if target_program != VICARE_PROGRAM_NORMAL: - # And we can't explicitly activate "normal", either + if target_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_STANDBY, + ]: + # And we can't explicitly activate "normal" or "standby", either _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) From b685584b91908707e84ac1bad0fc81e93f6c83ce Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 28 Dec 2023 09:35:39 +0100 Subject: [PATCH 819/927] Handle AttributeError in ViCare integration (#106470) --- homeassistant/components/vicare/utils.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 5b3fb38337f..a084eee383b 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -21,13 +21,12 @@ def is_supported( try: entity_description.value_getter(vicare_device) _LOGGER.debug("Found entity %s", name) + return True except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return False + _LOGGER.debug("Feature not supported %s", name) except AttributeError as error: - _LOGGER.debug("Attribute Error %s: %s", name, error) - return False - return True + _LOGGER.debug("Feature not supported %s: %s", name, error) + return False def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: @@ -36,6 +35,8 @@ def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: return device.burners except PyViCareNotSupportedFeatureError: _LOGGER.debug("No burners found") + except AttributeError as error: + _LOGGER.debug("No burners found: %s", error) return [] @@ -45,6 +46,8 @@ def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent] return device.circuits except PyViCareNotSupportedFeatureError: _LOGGER.debug("No circuits found") + except AttributeError as error: + _LOGGER.debug("No circuits found: %s", error) return [] @@ -54,4 +57,6 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone return device.compressors except PyViCareNotSupportedFeatureError: _LOGGER.debug("No compressors found") + except AttributeError as error: + _LOGGER.debug("No compressors found: %s", error) return [] From b8ddd61b26016e5bdf968db2708bc443a440d4f5 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 28 Dec 2023 11:17:13 +0100 Subject: [PATCH 820/927] Avoid changing state of reduced preset in ViCare integration (#105642) --- homeassistant/components/vicare/climate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 0b8e3cab865..2bb0a19924e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -313,9 +313,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _LOGGER.debug("Current preset %s", self._current_program) if self._current_program and self._current_program not in [ VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, VICARE_PROGRAM_STANDBY, ]: - # We can't deactivate "normal" or "standby" + # We can't deactivate "normal", "reduced" or "standby" _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -331,9 +332,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) if target_program not in [ VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, VICARE_PROGRAM_STANDBY, ]: - # And we can't explicitly activate "normal" or "standby", either + # And we can't explicitly activate "normal", "reduced" or "standby", either _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) From 50acf85f48e1b539c2d780c0d482c8052e5127d5 Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Thu, 28 Dec 2023 20:30:26 +0100 Subject: [PATCH 821/927] Use correct state for emulated_hue covers (#106516) --- .../components/emulated_hue/hue_api.py | 15 ++++++++--- tests/components/emulated_hue/test_hue_api.py | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index ad6b0541cd6..05e5c1ece07 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -57,6 +57,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, + STATE_CLOSED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -73,6 +74,7 @@ from homeassistant.util.network import is_local from .config import Config _LOGGER = logging.getLogger(__name__) +_OFF_STATES: dict[str, str] = {cover.DOMAIN: STATE_CLOSED} # How long to wait for a state change to happen STATE_CHANGE_WAIT_TIMEOUT = 5.0 @@ -394,7 +396,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: - parsed[STATE_ON] = entity.state != STATE_OFF + parsed[STATE_ON] = _hass_to_hue_state(entity) for key, attr in ( (HUE_API_STATE_BRI, STATE_BRIGHTNESS), @@ -585,7 +587,7 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: - state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity) hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) @@ -643,7 +645,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: cached_state = entry_state elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ STATE_ON - ] == (entity.state != STATE_OFF): + ] == _hass_to_hue_state(entity): # We only want to use the cache if the actual state of the entity # is in sync so that it can be detected as an error by Alexa. cached_state = entry_state @@ -676,7 +678,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" - is_on = entity.state != STATE_OFF + is_on = _hass_to_hue_state(entity) data: dict[str, Any] = { STATE_ON: is_on, STATE_BRIGHTNESS: None, @@ -891,6 +893,11 @@ def hass_to_hue_brightness(value: int) -> int: return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) +def _hass_to_hue_state(entity: State) -> bool: + """Convert hass entity states to simple True/False on/off state for Hue.""" + return entity.state != _OFF_STATES.get(entity.domain, STATE_OFF) + + async def wait_for_state_change_or_timeout( hass: core.HomeAssistant, entity_id: str, timeout: float ) -> None: diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 3febc42730b..167562578f2 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1019,6 +1019,12 @@ async def test_set_position_cover(hass_hue, hue_client) -> None: cover_test = hass_hue.states.get(cover_id) assert cover_test.state == "closed" + cover_json = await perform_get_light_state( + hue_client, "cover.living_room_window", HTTPStatus.OK + ) + assert cover_json["state"][HUE_API_STATE_ON] is False + assert cover_json["state"][HUE_API_STATE_BRI] == 1 + level = 20 brightness = round(level / 100 * 254) @@ -1095,6 +1101,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 await perform_put_light_state( @@ -1112,6 +1119,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert ( round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 66 ) # small rounding error in inverse operation @@ -1132,8 +1140,27 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + False, + brightness=0, + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_PERCENTAGE] == 0 + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTPStatus.OK + ) + assert fan_json["state"][HUE_API_STATE_ON] is False + assert fan_json["state"][HUE_API_STATE_BRI] == 1 + async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" From 42ffb51b76a1270d7dbcfe9f99a473b379b83413 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 28 Dec 2023 18:00:34 +1000 Subject: [PATCH 822/927] Fix Tessie honk button (#106518) --- homeassistant/components/tessie/button.py | 2 +- tests/components/tessie/test_button.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index 817bdb3a87c..86065d389a4 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -35,7 +35,7 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( TessieButtonEntityDescription( key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" ), - TessieButtonEntityDescription(key="honk", func=honk, icon="mdi:bullhorn"), + TessieButtonEntityDescription(key="honk", func=lambda: honk, icon="mdi:bullhorn"), TessieButtonEntityDescription( key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" ), diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index 72e458cb5d6..153171c8b9f 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -1,6 +1,8 @@ """Test the Tessie button platform.""" from unittest.mock import patch +import pytest + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -8,19 +10,30 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -async def test_buttons(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("entity_id", "func"), + [ + ("button.test_wake", "wake"), + ("button.test_flash_lights", "flash_lights"), + ("button.test_honk_horn", "honk"), + ("button.test_homelink", "trigger_homelink"), + ("button.test_keyless_driving", "enable_keyless_driving"), + ("button.test_play_fart", "boombox"), + ], +) +async def test_buttons(hass: HomeAssistant, entity_id, func) -> None: """Tests that the button entities are correct.""" await setup_platform(hass) # Test wake button with patch( - "homeassistant.components.tessie.button.wake", + f"homeassistant.components.tessie.button.{func}", ) as mock_wake: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: ["button.test_wake"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_wake.assert_called_once() From 0de6030911fa2819c91312fdc08d8bb023e131eb Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 28 Dec 2023 18:02:04 +1000 Subject: [PATCH 823/927] Fix run errors in Tessie (#106521) --- homeassistant/components/tessie/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index fc6e8939da9..be80caf50cb 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -52,7 +52,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): return self.coordinator.data.get(key or self.key, default) async def run( - self, func: Callable[..., Awaitable[dict[str, bool]]], **kargs: Any + self, func: Callable[..., Awaitable[dict[str, bool | str]]], **kargs: Any ) -> None: """Run a tessie_api function and handle exceptions.""" try: @@ -66,7 +66,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): raise HomeAssistantError from e if response["result"] is False: raise HomeAssistantError( - response.get("reason"), "An unknown issue occurred" + response.get("reason", "An unknown issue occurred") ) def set(self, *args: Any) -> None: From 227a69da651d5890d96bf9c0ad506ede38d63cab Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 17:45:21 +0100 Subject: [PATCH 824/927] Add missing disks to Systemmonitor (#106541) --- homeassistant/components/systemmonitor/util.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index bb81d0c9715..27c4c449634 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -11,14 +11,16 @@ _LOGGER = logging.getLogger(__name__) def get_all_disk_mounts() -> list[str]: """Return all disk mount points on system.""" disks: list[str] = [] - for part in psutil.disk_partitions(all=False): + for part in psutil.disk_partitions(all=True): if os.name == "nt": if "cdrom" in part.opts or part.fstype == "": # skip cd-rom drives with no disk in it; they may raise # ENOENT, pop-up a Windows GUI error for a non-ready # partition or just hang. continue - disks.append(part.mountpoint) + usage = psutil.disk_usage(part.mountpoint) + if usage.total > 0 and part.device != "": + disks.append(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) return disks From 571ba0efb093e3e629b506613903ed183fadbd99 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 16:05:52 +0100 Subject: [PATCH 825/927] Bump python-holidays to 0.39 (#106550) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 50536bc201d..7417cc5cd51 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.38", "babel==2.13.1"] + "requirements": ["holidays==0.39", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 92face1ecdb..ae7c42c1868 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.38"] + "requirements": ["holidays==0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef621c9ce94..1f4b7d030ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1035,7 +1035,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.38 +holidays==0.39 # homeassistant.components.frontend home-assistant-frontend==20231227.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e3256385fc..16e0897c412 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -825,7 +825,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.38 +holidays==0.39 # homeassistant.components.frontend home-assistant-frontend==20231227.0 From 1d0fafcf2df537dc01675ce8e427021083644346 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 28 Dec 2023 14:20:56 +0100 Subject: [PATCH 826/927] Remove default value from modbus retries (#106551) Solve retries issue. --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 74a1de48c0a..141f2b0cca6 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -374,7 +374,7 @@ MODBUS_SCHEMA = vol.Schema( vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, - vol.Optional(CONF_RETRIES, default=3): cv.positive_int, + vol.Optional(CONF_RETRIES): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1d755adace7..95c0cd45332 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -278,6 +278,8 @@ class ModbusHub: _LOGGER.warning( "`retries`: is deprecated and will be removed in version 2024.7" ) + else: + client_config[CONF_RETRIES] = 3 if CONF_CLOSE_COMM_ON_ERROR in client_config: async_create_issue( hass, From d7a697faf45b48ce806bcb77ddf111eb404adf03 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 16:10:27 +0100 Subject: [PATCH 827/927] Fix holiday HA language not supported (#106554) --- .../components/holiday/config_flow.py | 16 +++++++++++++--- tests/components/holiday/test_config_flow.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 1ba4a2a0c26..842849a7c57 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from babel import Locale +from babel import Locale, UnknownLocaleError from holidays import list_supported_countries import voluptuous as vol @@ -46,7 +46,12 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) - locale = Locale(self.hass.config.language) + try: + locale = Locale(self.hass.config.language) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") title = locale.territories[selected_country] return self.async_create_entry(title=title, data=user_input) @@ -81,7 +86,12 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) - locale = Locale(self.hass.config.language) + try: + locale = Locale(self.hass.config.language) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") province_str = f", {province}" if province else "" name = f"{locale.territories[country]}{province_str}" diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index e99d310762e..c88d66d843b 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -126,3 +126,22 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: ) assert result_de_step2["type"] == FlowResultType.ABORT assert result_de_step2["reason"] == "already_configured" + + +async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: + """Test the config flow if using not babel supported language.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Sweden" From 285bb5632d64422d0c616a9702fac580d14fcc25 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Dec 2023 16:05:11 +0100 Subject: [PATCH 828/927] Update frontend to 20231228.0 (#106556) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9c631b4cfd5..227fa96edf7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231227.0"] + "requirements": ["home-assistant-frontend==20231228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 18a8b14b9d5..a6c59c98dc0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.0 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1f4b7d030ac..c2ba8cccf52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16e0897c412..1171fbf1970 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -828,7 +828,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From d24a923a7308b1e519690d06eb303182f522dd67 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 20:16:14 +0100 Subject: [PATCH 829/927] Replace dash in language if needed (#106559) * Replace dash in language if needed * Add tests --- .../components/holiday/config_flow.py | 4 +- tests/components/holiday/test_config_flow.py | 79 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 842849a7c57..33268de92b6 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -47,7 +47,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) try: - locale = Locale(self.hass.config.language) + locale = Locale(self.hass.config.language.replace("-", "_")) except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" @@ -87,7 +87,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - locale = Locale(self.hass.config.language) + locale = Locale(self.hass.config.language.replace("-", "_")) except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index c88d66d843b..7dce6131616 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -130,13 +130,13 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: """Test the config flow if using not babel supported language.""" - hass.config.language = "en-GB" + hass.config.language = "en-XX" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_COUNTRY: "SE", @@ -144,4 +144,77 @@ async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["title"] == "Sweden" + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } + + +async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> None: + """Test the config flow if using language with dash.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } From 0e0cd8e7deb57edeb2bff37f3db6eef7dea13d6a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 28 Dec 2023 17:37:48 +0100 Subject: [PATCH 830/927] Remove default value for modbus lazy_error (#106561) --- homeassistant/components/modbus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 141f2b0cca6..89a50862b6c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -157,7 +157,7 @@ BASE_COMPONENT_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, - vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) From a111e35026795857d1f58495bef9999423c22b56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 28 Dec 2023 20:20:59 +0100 Subject: [PATCH 831/927] Only check known attributes in significant change support (#106572) only check known attributes --- .../alarm_control_panel/significant_change.py | 13 +++++++----- .../components/climate/significant_change.py | 12 ++++++----- .../components/cover/significant_change.py | 11 +++++----- .../components/fan/significant_change.py | 20 ++++++++++++------- .../humidifier/significant_change.py | 11 +++++----- .../media_player/significant_change.py | 19 +++++++++++++----- .../components/vacuum/significant_change.py | 11 +++++----- .../water_heater/significant_change.py | 11 +++++----- .../components/weather/significant_change.py | 11 +++++----- 9 files changed, 72 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py index d33347a67f1..bde6d151393 100644 --- a/homeassistant/components/alarm_control_panel/significant_change.py +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -26,13 +26,16 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} - for attr_name in changed_attrs: - if attr_name in SIGNIFICANT_ATTRIBUTES: - return True + if changed_attrs: + return True # no significant attribute change detected return False diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 01d3ef98558..7198153f9af 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -52,15 +52,17 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} ha_unit = hass.config.units.temperature_unit for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ ATTR_AUX_HEAT, ATTR_FAN_MODE, diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py index 8762af496c8..ca822c5e9e1 100644 --- a/homeassistant/components/cover/significant_change.py +++ b/homeassistant/components/cover/significant_change.py @@ -30,14 +30,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - old_attr_value = old_attrs.get(attr_name) new_attr_value = new_attrs.get(attr_name) if new_attr_value is None or not check_valid_float(new_attr_value): diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py index 19c43522f35..b8038b93f79 100644 --- a/homeassistant/components/fan/significant_change.py +++ b/homeassistant/components/fan/significant_change.py @@ -9,9 +9,14 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP +from . import ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE -INSIGNIFICANT_ATTRIBUTES: set[str] = {ATTR_PERCENTAGE_STEP} +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, +} @callback @@ -27,14 +32,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name in INSIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_PERCENTAGE: return True diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py index 7acc1033d3f..cc279a9fa41 100644 --- a/homeassistant/components/humidifier/significant_change.py +++ b/homeassistant/components/humidifier/significant_change.py @@ -32,14 +32,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ATTR_ACTION, ATTR_MODE]: return True diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index b2a2e57d84f..3e11cbdb9cd 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -43,14 +43,23 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + { + k: v + for k, v in old_attrs.items() + if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES + }.items() + ) + new_attrs_s = set( + { + k: v + for k, v in new_attrs.items() + if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES + }.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_MEDIA_VOLUME_LEVEL: return True diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py index 3031d60305a..5699050c7cb 100644 --- a/homeassistant/components/vacuum/significant_change.py +++ b/homeassistant/components/vacuum/significant_change.py @@ -30,14 +30,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_BATTERY_LEVEL: return True diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py index 903c80bb714..bacb0232ee3 100644 --- a/homeassistant/components/water_heater/significant_change.py +++ b/homeassistant/components/water_heater/significant_change.py @@ -42,15 +42,16 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} ha_unit = hass.config.units.temperature_unit for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: return True diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index 4bb67c54e19..87e1246ce85 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -88,14 +88,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - old_attr_value = old_attrs.get(attr_name) new_attr_value = new_attrs.get(attr_name) absolute_change: float | None = None From e1e697c16eb103b304043c400fd7f45cba3833b0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 28 Dec 2023 13:36:57 -0500 Subject: [PATCH 832/927] Bump plexapi to 4.15.7 (#106576) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 6dbd6118d7c..8fc01140787 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.6", + "PlexAPI==4.15.7", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/requirements_all.txt b/requirements_all.txt index c2ba8cccf52..ed173f9b472 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.6 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1171fbf1970..8cdd44e284e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.8.0 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.6 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 From 104039e7327bdf82eb91432d705a576155756d28 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:53:56 +0100 Subject: [PATCH 833/927] Revert "Set volume_step in aquostv media_player" (#106577) Revert "Set volume_step in aquostv media_player (#105665)" This reverts commit bb8dce6187b93ea17bf04902574b9c133a887e05. --- .../components/aquostv/media_player.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index cd93ddf9e15..34d5e4161fb 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -112,7 +112,6 @@ class SharpAquosTVDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 2 / 60 def __init__( self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False @@ -157,6 +156,22 @@ class SharpAquosTVDevice(MediaPlayerEntity): """Turn off tvplayer.""" self._remote.power(0) + @_retry + def volume_up(self) -> None: + """Volume up the media player.""" + if self.volume_level is None: + _LOGGER.debug("Unknown volume in volume_up") + return + self._remote.volume(int(self.volume_level * 60) + 2) + + @_retry + def volume_down(self) -> None: + """Volume down media player.""" + if self.volume_level is None: + _LOGGER.debug("Unknown volume in volume_down") + return + self._remote.volume(int(self.volume_level * 60) - 2) + @_retry def set_volume_level(self, volume: float) -> None: """Set Volume media player.""" From 72dd60e66724b29c15beeb1cc4ae8f4cb5bdff84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:10 +0100 Subject: [PATCH 834/927] Revert "Set volume_step in clementine media_player" (#106578) Revert "Set volume_step in clementine media_player (#105666)" This reverts commit 36eeb15feedac126b3465d3c522a858a9cc9ac2e. --- homeassistant/components/clementine/media_player.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index eb0da23d360..770f19e9970 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -65,7 +65,6 @@ class ClementineDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 4 / 100 def __init__(self, client, name): """Initialize the Clementine device.""" @@ -124,6 +123,16 @@ class ClementineDevice(MediaPlayerEntity): return None, None + def volume_up(self) -> None: + """Volume up the media player.""" + newvolume = min(self._client.volume + 4, 100) + self._client.set_volume(newvolume) + + def volume_down(self) -> None: + """Volume down media player.""" + newvolume = max(self._client.volume - 4, 0) + self._client.set_volume(newvolume) + def mute_volume(self, mute: bool) -> None: """Send mute command.""" self._client.set_volume(0) From 925b851366585e8222aa7c2c51b841c6062f5bdc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:21 +0100 Subject: [PATCH 835/927] Revert "Set volume_step in cmus media_player" (#106579) Revert "Set volume_step in cmus media_player (#105667)" This reverts commit c10b460c6bf71cb0329dca991b7a09fc5cd963c4. --- homeassistant/components/cmus/media_player.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index a242a5a772c..65bfef3a0cb 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -94,7 +94,6 @@ class CmusDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 5 / 100 def __init__(self, device, name, server): """Initialize the CMUS device.""" @@ -154,6 +153,30 @@ class CmusDevice(MediaPlayerEntity): """Set volume level, range 0..1.""" self._remote.cmus.set_volume(int(volume * 100)) + def volume_up(self) -> None: + """Set the volume up.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self._remote.cmus.set_volume(int(current_volume) + 5) + + def volume_down(self) -> None: + """Set the volume down.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self._remote.cmus.set_volume(int(current_volume) - 5) + def play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: From e953587260eb0a4c13000371b4742bb56b5a8782 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:36 +0100 Subject: [PATCH 836/927] Revert "Set volume_step in monoprice media_player" (#106580) Revert "Set volume_step in monoprice media_player (#105670)" This reverts commit cffb51ebec5a681878f7acd88d10e1e53e8130ce. --- .../components/monoprice/media_player.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 40ea9f85a7c..92b98abf374 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -127,7 +127,6 @@ class MonopriceZone(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None - _attr_volume_step = 1 / MAX_VOLUME def __init__(self, monoprice, sources, namespace, zone_id): """Initialize new zone.""" @@ -211,3 +210,17 @@ class MonopriceZone(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME)) + + def volume_up(self) -> None: + """Volume up the media player.""" + if self.volume_level is None: + return + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME)) + + def volume_down(self) -> None: + """Volume down media player.""" + if self.volume_level is None: + return + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, max(volume - 1, 0)) From 35b9044187b32fd05c617944e2fde3bd8cd2e194 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:48:34 +0100 Subject: [PATCH 837/927] Revert "Set volume_step in sonos media_player" (#106581) Revert "Set volume_step in sonos media_player (#105671)" This reverts commit 6dc8c2c37014de201578b5cbe880f7a1bbcecfc4. --- homeassistant/components/sonos/media_player.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 031e4606148..27059bba180 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -67,6 +67,7 @@ _LOGGER = logging.getLogger(__name__) LONG_SERVICE_TIMEOUT = 30.0 UNJOIN_SERVICE_TIMEOUT = 0.1 +VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { RepeatMode.OFF: False, @@ -211,7 +212,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER - _attr_volume_step = 2 / 100 def __init__(self, speaker: SonosSpeaker) -> None: """Initialize the media player entity.""" @@ -373,6 +373,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Name of the current input source.""" return self.media.source_name or None + @soco_error() + def volume_up(self) -> None: + """Volume up media player.""" + self.soco.volume += VOLUME_INCREMENT + + @soco_error() + def volume_down(self) -> None: + """Volume down media player.""" + self.soco.volume -= VOLUME_INCREMENT + @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" From 5125d8622d22cd040dddf5008b68fb65fde1210b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:51 +0100 Subject: [PATCH 838/927] Revert "Set volume_step in bluesound media_player" (#106582) Revert "Set volume_step in bluesound media_player (#105672)" This reverts commit 7fa55ffdd29af9d428b0ebd06b59b7be16130e3a. --- .../components/bluesound/media_player.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index cfe2fedebdc..eba03963ebc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -200,7 +200,6 @@ class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC - _attr_volume_step = 0.01 def __init__(self, hass, host, port=None, name=None, init_callback=None): """Initialize the media player.""" @@ -1028,6 +1027,20 @@ class BluesoundPlayer(MediaPlayerEntity): return await self.send_bluesound_command(url) + async def async_volume_up(self) -> None: + """Volume up the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol >= 1: + return + return await self.async_set_volume_level(current_vol + 0.01) + + async def async_volume_down(self) -> None: + """Volume down the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol <= 0: + return + return await self.async_set_volume_level(current_vol - 0.01) + async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" if volume < 0: From df894acefa5bca0e2e221f8b7b3e74ea14005141 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:50:57 +0100 Subject: [PATCH 839/927] Revert "Set volume_step in frontier_silicon media_player" (#106583) Revert "Set volume_step in frontier_silicon media_player (#105953)" This reverts commit 3e50ca6cda06a693002e0e7c69cbaa214145d053. --- .../components/frontier_silicon/media_player.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 565ee79b108..223abe26e55 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -152,9 +152,6 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._max_volume: self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 - if self._max_volume: - self._attr_volume_step = 1 / self._max_volume - if self._attr_state != MediaPlayerState.OFF: info_name = await afsapi.get_play_name() info_text = await afsapi.get_play_text() @@ -242,6 +239,18 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_mute(mute) # volume + async def async_volume_up(self) -> None: + """Send volume up command.""" + volume = await self.fs_device.get_volume() + volume = int(volume or 0) + 1 + await self.fs_device.set_volume(min(volume, self._max_volume)) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + volume = await self.fs_device.get_volume() + volume = int(volume or 0) - 1 + await self.fs_device.set_volume(max(volume, 0)) + async def async_set_volume_level(self, volume: float) -> None: """Set volume command.""" if self._max_volume: # Can't do anything sensible if not set From fa34cbc41450eb74bd33b38ab1252bdf62cac95d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 20:39:39 +0100 Subject: [PATCH 840/927] Systemmonitor always load imported disks (#106546) * Systemmonitor always load legacy disks * loaded_resources --- .../components/systemmonitor/sensor.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 57838c45dc7..2bc1406308c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -389,6 +389,7 @@ async def async_setup_entry( entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} legacy_resources: list[str] = entry.options.get("resources", []) + loaded_resources: list[str] = [] disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) @@ -404,6 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -423,6 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -446,6 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -459,6 +463,7 @@ async def async_setup_entry( sensor_registry[(_type, "")] = SensorData("", None, None, None, None) is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) + loaded_resources.append(f"{_type}_") entities.append( SystemMonitorSensor( sensor_registry, @@ -469,6 +474,31 @@ async def async_setup_entry( ) ) + # Ensure legacy imported disk_* resources are loaded if they are not part + # of mount points automatically discovered + for resource in legacy_resources: + if resource.startswith("disk_"): + _LOGGER.debug( + "Check resource %s already loaded in %s", resource, loaded_resources + ) + if resource not in loaded_resources: + split_index = resource.rfind("_") + _type = resource[:split_index] + argument = resource[split_index + 1 :] + _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + SENSOR_TYPES[_type], + entry.entry_id, + argument, + True, + ) + ) + scan_interval = DEFAULT_SCAN_INTERVAL await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) async_add_entities(entities) From 9de482f4295a92bdeafdf1849e41d7f70e9fbe50 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 28 Dec 2023 15:08:55 -0500 Subject: [PATCH 841/927] Cleanup Sonos subscription used during setup (#106575) --- homeassistant/components/sonos/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e6b328cbcb0..c79856c58b6 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -290,6 +290,17 @@ class SonosDiscoveryManager: sub.callback = _async_subscription_succeeded # Hold lock to prevent concurrent subscription attempts await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2) + try: + # Cancel this subscription as we create an autorenewing + # subscription when setting up the SonosSpeaker instance + await sub.unsubscribe() + except ClientError as ex: + # Will be rejected if already replaced by new subscription + _LOGGER.debug( + "Cleanup unsubscription from %s was rejected: %s", ip_address, ex + ) + except (OSError, Timeout) as ex: + _LOGGER.error("Cleanup unsubscription from %s failed: %s", ip_address, ex) async def _async_stop_event_listener(self, event: Event | None = None) -> None: for speaker in self.data.discovered.values(): From 2ffb033a4618ff37b4733b21dcc32c428a615f99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 21:08:16 +0100 Subject: [PATCH 842/927] Revert "Set volume_step in enigma2 media_player" (#106584) --- homeassistant/components/enigma2/media_player.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 4c0911b2462..432823d781b 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -119,7 +119,6 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE ) - _attr_volume_step = 5 / 100 def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" @@ -141,6 +140,18 @@ class Enigma2Device(MediaPlayerEntity): """Set volume level, range 0..1.""" await self._device.set_volume(int(volume * 100)) + async def async_volume_up(self) -> None: + """Volume up the media player.""" + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) + 5) + + async def async_volume_down(self) -> None: + """Volume down media player.""" + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) - 5) + async def async_media_stop(self) -> None: """Send stop command.""" await self._device.send_remote_control_action(RemoteControlCodes.STOP) From fc021f863373f7c3e9ce6297d1ce0332a9bd799e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 10:18:05 -1000 Subject: [PATCH 843/927] Bump aiohomekit to 3.1.1 (#106591) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e6ef6d58df6..edb81c14a72 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.0"], + "requirements": ["aiohomekit==3.1.1"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ed173f9b472..5e22f7a0815 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.0 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cdd44e284e..4360fc394c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.0 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From c9f12d45b43a76da7d00b1b15fb159aaadc600b1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Dec 2023 21:19:27 +0100 Subject: [PATCH 844/927] Bump version to 2024.1.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 572aa5d743d..6afa0430ba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 33b01c3c288..9d0794d3d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b0" +version = "2024.1.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 256e3e059731e3d298563312f63a53b75cc6dcb3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Dec 2023 21:20:55 +0100 Subject: [PATCH 845/927] Revert "Bump version to 2024.1.0" This reverts commit c9f12d45b43a76da7d00b1b15fb159aaadc600b1. --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6afa0430ba3..572aa5d743d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 9d0794d3d2d..33b01c3c288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0" +version = "2024.1.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2b7d37cbc2fa265fae0ea79511d369d15aaf7fd0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Dec 2023 21:21:15 +0100 Subject: [PATCH 846/927] Bump version to 2024.1.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 572aa5d743d..f2387299576 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 33b01c3c288..baa6814c75b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b0" +version = "2024.1.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d407b9fca86a39f3e98f6d586cba72b95a9efcfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:10:26 -1000 Subject: [PATCH 847/927] Update platform back-compat for custom components without UpdateEntityFeature (#106528) --- homeassistant/components/update/__init__.py | 21 +++++++++++++++++---- tests/components/update/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 43a2a3e785f..40431332aaf 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -263,7 +263,7 @@ class UpdateEntity( return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if UpdateEntityFeature.INSTALL in self.supported_features: + if UpdateEntityFeature.INSTALL in self.supported_features_compat: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -322,6 +322,19 @@ class UpdateEntity( """ return self._attr_title + @property + def supported_features_compat(self) -> UpdateEntityFeature: + """Return the supported features as UpdateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = UpdateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @final async def async_skip(self) -> None: """Skip the current offered version to update.""" @@ -408,7 +421,7 @@ class UpdateEntity( # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if UpdateEntityFeature.PROGRESS in self.supported_features: + if UpdateEntityFeature.PROGRESS in self.supported_features_compat: in_progress = self.in_progress else: in_progress = self.__in_progress @@ -444,7 +457,7 @@ class UpdateEntity( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if UpdateEntityFeature.PROGRESS not in self.supported_features: + if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: self.__in_progress = True self.async_write_ha_state() @@ -490,7 +503,7 @@ async def websocket_release_notes( ) return - if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_SUPPORTED, diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 629c6838654..92e63af4b6f 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -865,3 +865,23 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state assert expected.items() <= state.attributes.items() + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockUpdateEntity(UpdateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockUpdateEntity() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "MockUpdateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "UpdateEntityFeature.INSTALL" in caplog.text + caplog.clear() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 0623972ee08be7072cb7cf1bee872dec73d4889d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:45:35 -1000 Subject: [PATCH 848/927] Camera platform back-compat for custom components without CameraEntityFeature (#106529) --- homeassistant/components/camera/__init__.py | 17 +++++++++++++++-- tests/components/camera/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 9f5ec0a6740..7a56292f7bb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -530,6 +530,19 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -570,7 +583,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ if hasattr(self, "_attr_frontend_stream_type"): return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if self._rtsp_to_webrtc: return StreamType.WEB_RTC @@ -758,7 +771,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def _async_use_rtsp_to_webrtc(self) -> bool: """Determine if a WebRTC provider can be used for the camera.""" - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return False if DATA_RTSP_TO_WEB_RTC not in self.hass.data: return False diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index cb9b09a85ab..0e761f2f437 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -993,3 +993,23 @@ def test_deprecated_support_constants( import_and_test_deprecated_constant_enum( caplog, camera, entity_feature, "SUPPORT_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 55877b0953d8d9071cb1795ebdf45744f4a8c20a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 28 Dec 2023 13:24:11 -0800 Subject: [PATCH 849/927] Rename domain aepohio to aep_ohio (#106536) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/{aepohio => aep_ohio}/__init__.py | 0 homeassistant/components/{aepohio => aep_ohio}/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename homeassistant/components/{aepohio => aep_ohio}/__init__.py (100%) rename homeassistant/components/{aepohio => aep_ohio}/manifest.json (78%) diff --git a/homeassistant/components/aepohio/__init__.py b/homeassistant/components/aep_ohio/__init__.py similarity index 100% rename from homeassistant/components/aepohio/__init__.py rename to homeassistant/components/aep_ohio/__init__.py diff --git a/homeassistant/components/aepohio/manifest.json b/homeassistant/components/aep_ohio/manifest.json similarity index 78% rename from homeassistant/components/aepohio/manifest.json rename to homeassistant/components/aep_ohio/manifest.json index f659a712016..9b85e537fc8 100644 --- a/homeassistant/components/aepohio/manifest.json +++ b/homeassistant/components/aep_ohio/manifest.json @@ -1,5 +1,5 @@ { - "domain": "aepohio", + "domain": "aep_ohio", "name": "AEP Ohio", "integration_type": "virtual", "supported_by": "opower" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 995609ec226..884ca88074e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,7 +65,7 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "aepohio": { + "aep_ohio": { "name": "AEP Ohio", "integration_type": "virtual", "supported_by": "opower" From 911234ae8f4f864ebeaa0a22d84d24b776bb84d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Dec 2023 21:58:34 +0100 Subject: [PATCH 850/927] Move aeptexas to aep_texas (#106595) --- homeassistant/components/{aeptexas => aep_texas}/__init__.py | 0 homeassistant/components/{aeptexas => aep_texas}/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename homeassistant/components/{aeptexas => aep_texas}/__init__.py (100%) rename homeassistant/components/{aeptexas => aep_texas}/manifest.json (77%) diff --git a/homeassistant/components/aeptexas/__init__.py b/homeassistant/components/aep_texas/__init__.py similarity index 100% rename from homeassistant/components/aeptexas/__init__.py rename to homeassistant/components/aep_texas/__init__.py diff --git a/homeassistant/components/aeptexas/manifest.json b/homeassistant/components/aep_texas/manifest.json similarity index 77% rename from homeassistant/components/aeptexas/manifest.json rename to homeassistant/components/aep_texas/manifest.json index d6260a2f51a..5de0e0ffd77 100644 --- a/homeassistant/components/aeptexas/manifest.json +++ b/homeassistant/components/aep_texas/manifest.json @@ -1,5 +1,5 @@ { - "domain": "aeptexas", + "domain": "aep_texas", "name": "AEP Texas", "integration_type": "virtual", "supported_by": "opower" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 884ca88074e..45bcc1788cd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -70,7 +70,7 @@ "integration_type": "virtual", "supported_by": "opower" }, - "aeptexas": { + "aep_texas": { "name": "AEP Texas", "integration_type": "virtual", "supported_by": "opower" From 982707afe61eff7adbd84f656f5592e5ee1b750c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 28 Dec 2023 16:26:19 -0500 Subject: [PATCH 851/927] Fix Netgear LTE halting startup (#106598) --- homeassistant/components/netgear_lte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 00a43282210..9faa2f361b9 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -170,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_HASS_CONFIG] = config if lte_config := config.get(DOMAIN): - await hass.async_create_task(import_yaml(hass, lte_config)) + hass.async_create_task(import_yaml(hass, lte_config)) return True From 16192cd7f2594f85f987e8bfc618e9f2e9e2f9fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 12:24:36 -1000 Subject: [PATCH 852/927] Add helper to report deprecated entity supported features magic numbers (#106602) --- homeassistant/helpers/entity.py | 30 +++++++++++++++++++++++++++++- tests/helpers/test_entity.py | 26 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8f344aff484..fc627f51acf 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping import dataclasses from datetime import timedelta -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -460,6 +460,9 @@ class Entity( # If we reported if this entity was slow _slow_reported = False + # If we reported deprecated supported features constants + _deprecated_supported_features_reported = False + # If we reported this entity is updated while disabled _disabled_reported = False @@ -1496,6 +1499,31 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf90660f31..96bbf95a986 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any @@ -2025,3 +2026,28 @@ async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> No for ent in entities: assert getattr(ent[0], property) == values[1] assert getattr(ent[1], property) == values[0] + + +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) From 06f06b7595f3f1a32c7f74423055e1db37edc497 Mon Sep 17 00:00:00 2001 From: Joe Neuman Date: Fri, 29 Dec 2023 02:08:40 -0800 Subject: [PATCH 853/927] Fix count bug in qBittorrent (#106603) --- homeassistant/components/qbittorrent/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index a51ff58405c..9373aec8544 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -165,6 +165,9 @@ def count_torrents_in_states( coordinator: QBittorrentDataCoordinator, states: list[str] ) -> int: """Count the number of torrents in specified states.""" + if not states: + return len(coordinator.data["torrents"]) + return len( [ torrent From 4a98a6465e2bd085f6e0dc98f0331ed9dfa75e07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:36:28 -1000 Subject: [PATCH 854/927] Climate platform back-compat for custom components without ClimateEntityFeature (#106605) --- homeassistant/components/climate/__init__.py | 17 +++++++++++++++-- tests/components/climate/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 4815b7a1cbb..19e26265f70 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -316,7 +316,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -349,7 +349,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -665,6 +665,19 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> ClimateEntityFeature: + """Return the supported features as ClimateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = ClimateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index f46e0902c66..8fc82365c23 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -333,3 +333,23 @@ async def test_preset_mode_validation( ) assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" assert exc.value.translation_key == "not_valid_fan_mode" + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockClimateEntity(ClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockClimateEntity() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "MockClimateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 04fe8260abe8e2bc2cd423a80913c6663cb10ed9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:36:15 -1000 Subject: [PATCH 855/927] Fan platform back-compat for custom components without FanEntityFeature (#106607) --- homeassistant/components/fan/__init__.py | 17 +++++++++++++++-- tests/components/fan/test_init.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 1bacc6d8dac..dedaedfe600 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -400,7 +400,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( FanEntityFeature.SET_SPEED in supported_features @@ -415,7 +415,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if FanEntityFeature.DIRECTION in supported_features: data[ATTR_DIRECTION] = self.current_direction @@ -439,6 +439,19 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> FanEntityFeature: + """Return the supported features as FanEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = FanEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite. diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index e6a3ab546cc..828c13b6f16 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.fan import ( DOMAIN, SERVICE_SET_PRESET_MODE, FanEntity, + FanEntityFeature, NotValidPresetModeError, ) from homeassistant.core import HomeAssistant @@ -156,3 +157,23 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockFan(FanEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockFan() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "MockFan" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "FanEntityFeature.SET_SPEED" in caplog.text + caplog.clear() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 6224e630ac9c4cd0b5539e0295e4d9b10a5802a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:16:02 -1000 Subject: [PATCH 856/927] Water heater platform back-compat for custom components without WaterHeaterEntityFeature (#106608) --- .../components/water_heater/__init__.py | 13 ++++++++++++ tests/components/water_heater/test_init.py | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index ddef4e7366c..f2744416900 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -401,6 +401,19 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> WaterHeaterEntityFeature: + """Return the supported features as WaterHeaterEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = WaterHeaterEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 8a7d76bd891..0d33f3a9e93 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -115,3 +115,23 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, water_heater, enum, "SUPPORT_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockWaterHeaterEntity(WaterHeaterEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockWaterHeaterEntity() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "MockWaterHeaterEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "WaterHeaterEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From aa6e904e86f7bb70b0b345437a82628e8ac2ba91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:15:48 -1000 Subject: [PATCH 857/927] Remote platform back-compat for custom components without RemoteEntityFeature (#106609) --- homeassistant/components/remote/__init__.py | 15 ++++++++++++++- tests/components/remote/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 8c3d094710e..7e9ebfe12b9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -200,6 +200,19 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> RemoteEntityFeature: + """Return the supported features as RemoteEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = RemoteEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def current_activity(self) -> str | None: """Active activity.""" @@ -214,7 +227,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if RemoteEntityFeature.ACTIVITY not in self.supported_features: + if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: return None return { diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index b185b229cd2..a75ff858483 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -150,3 +150,23 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockRemote(remote.RemoteEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockRemote() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "MockRemote" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text + caplog.clear() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From f03bb4a2dac90005db4554cd13dde767dc6373d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:10:46 -1000 Subject: [PATCH 858/927] Humidifier platform back-compat for custom components without HumidifierEntityFeature (#106613) --- .../components/humidifier/__init__.py | 15 ++++++++++- tests/components/humidifier/test_init.py | 25 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 821cc8c4f37..75d4f0fd225 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -185,7 +185,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT ATTR_MAX_HUMIDITY: self.max_humidity, } - if HumidifierEntityFeature.MODES in self.supported_features: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_AVAILABLE_MODES] = self.available_modes return data @@ -280,3 +280,16 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> HumidifierEntityFeature: + """Return the supported features as HumidifierEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = HumidifierEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index da45e1f1661..45da5ba750f 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock import pytest from homeassistant.components import humidifier -from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier import ( + HumidifierEntity, + HumidifierEntityFeature, +) from homeassistant.core import HomeAssistant from tests.common import import_and_test_deprecated_constant_enum @@ -66,3 +69,23 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, module, enum, constant_prefix, "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockHumidifierEntity(HumidifierEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockHumidifierEntity() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "MockHumidifierEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "HumidifierEntityFeature.MODES" in caplog.text + caplog.clear() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 70842f197e0aa8b602a152a012955bbcf3df9e60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:06:25 -1000 Subject: [PATCH 859/927] Vacuum platform back-compat for custom components without VacuumEntityFeature (#106614) --- homeassistant/components/vacuum/__init__.py | 19 ++++++++++++--- tests/components/vacuum/test_init.py | 26 ++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 3ff29ec4e47..9a10da23824 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -258,6 +258,19 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -281,7 +294,7 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> Mapping[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -289,7 +302,7 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -471,7 +484,7 @@ class VacuumEntity( """Return the state attributes of the vacuum cleaner.""" data = super().state_attributes - if VacuumEntityFeature.STATUS in self.supported_features: + if VacuumEntityFeature.STATUS in self.supported_features_compat: data[ATTR_STATUS] = self.status return data diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 3cf77d4f420..0b44476989b 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,11 @@ from collections.abc import Generator import pytest -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntity +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -121,3 +125,23 @@ async def test_deprecated_base_class( issue.translation_placeholders == {"platform": "test"} | translation_placeholders_extra ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockVacuumEntity(VacuumEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockVacuumEntity() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "MockVacuumEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "VacuumEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 5d9177d6e6e427d85d83b65a53c47554121d9ec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:32:44 -1000 Subject: [PATCH 860/927] Media player platform back-compat for custom components without MediaPlayerEntityFeature (#106616) --- .../components/media_player/__init__.py | 49 ++++++++++++------- tests/components/media_player/test_init.py | 22 +++++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a4439c9c68e..113048421e1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -766,6 +766,19 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError() @@ -905,85 +918,87 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat + ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1012,7 +1027,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1030,7 +1045,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1073,7 +1088,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( source_list := self.source_list diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 377cdd32748..b4228d1ee69 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,6 +10,8 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerEnqueue, + MediaPlayerEntity, + MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF @@ -327,3 +329,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 9f4790902a4244275fd818d4aadff68aa4122d74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 21:34:08 -1000 Subject: [PATCH 861/927] Add deprecation warning for cover supported features when using magic numbers (#106618) --- homeassistant/components/cover/__init__.py | 8 ++++++-- tests/components/cover/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1a21908860a..3e438fb4ca1 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -340,8 +340,12 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - if self._attr_supported_features is not None: - return self._attr_supported_features + if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 062440e6b39..1b08658d983 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -141,3 +141,20 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, cover, enum, constant_prefix, "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 024d689b94aca1f952f5e9e9712695f66ebfba98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:04:06 -1000 Subject: [PATCH 862/927] Add deprecation warning for alarm_control_panel supported features when using magic numbers (#106619) --- .../alarm_control_panel/__init__.py | 7 +++++- .../alarm_control_panel/test_init.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index dd42c6c7072..9c53f2b7fd0 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -233,7 +233,12 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = AlarmControlPanelEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features @final @property diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index c447119c119..1e6fce6def6 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -45,3 +45,26 @@ def test_deprecated_support_alarm_constants( import_and_test_deprecated_constant_enum( caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): + _attr_supported_features = 1 + + entity = MockAlarmControlPanelEntity() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "MockAlarmControlPanelEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text + caplog.clear() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "is using deprecated supported features values" not in caplog.text From af9f6a2b12a741567194d99bd98ba864899c484c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:03:14 -1000 Subject: [PATCH 863/927] Add deprecation warning for lock supported features when using magic numbers (#106620) --- homeassistant/components/lock/__init__.py | 7 ++++++- tests/components/lock/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9a2466e22dd..a9370f8d092 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -278,7 +278,12 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = LockEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index c4337c367a9..854b89fd1d8 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -378,3 +378,20 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLockEntity(lock.LockEntity): + _attr_supported_features = 1 + + entity = MockLockEntity() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "MockLockEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LockEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 2147df4418fc1f1253892742a03f4b3c8e42c281 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:45:27 -1000 Subject: [PATCH 864/927] Add deprecation warning for siren supported features when using magic numbers (#106621) --- homeassistant/components/siren/__init__.py | 7 ++++++- tests/components/siren/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 263c6697df6..29ad238ac00 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -212,4 +212,9 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = SirenEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index ee007f6f1f5..abc5b0fac38 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -119,3 +119,20 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockSirenEntity(siren.SirenEntity): + _attr_supported_features = 1 + + entity = MockSirenEntity() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "MockSirenEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "SirenEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From f84d865c51239ecd54efa69116497830e3ed95d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:45:06 -1000 Subject: [PATCH 865/927] Migrate light entity to use contains for LightEntityFeature with deprecation warnings (#106622) --- homeassistant/components/light/__init__.py | 67 ++++++++++++++++------ tests/components/light/test_init.py | 21 +++++++ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c66562a53af..ebd3696d61f 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -345,11 +345,11 @@ def filter_turn_off_params( light: LightEntity, params: dict[str, Any] ) -> dict[str, Any]: """Filter out params not used in turn off or not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} @@ -357,13 +357,13 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) supported_color_modes = ( @@ -989,7 +989,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -1007,7 +1007,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( self.min_color_temp_kelvin ) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list data[ATTR_SUPPORTED_COLOR_MODES] = sorted(supported_color_modes) @@ -1061,8 +1061,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes + supported_features_value = supported_features.value color_mode = self._light_internal_color_mode if self.is_on else None if color_mode and color_mode not in supported_color_modes: @@ -1081,7 +1082,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features & SUPPORT_BRIGHTNESS: + elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1103,7 +1104,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - elif supported_features & SUPPORT_COLOR_TEMP: + elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1133,7 +1134,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if color_mode: data.update(self._light_internal_convert_color(color_mode)) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT] = self.effect if self.is_on else None return data @@ -1146,14 +1147,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Backwards compatibility for supported_color_modes added in 2021.4 # Add warning in 2021.6, remove in 2021.10 - supported_features = self.supported_features + supported_features = self.supported_features_compat + supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() - if supported_features & SUPPORT_COLOR_TEMP: + if supported_features_value & SUPPORT_COLOR_TEMP: supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features & SUPPORT_COLOR: + if supported_features_value & SUPPORT_COLOR: supported_color_modes.add(ColorMode.HS) - if supported_features & SUPPORT_BRIGHTNESS and not supported_color_modes: + if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: supported_color_modes = {ColorMode.BRIGHTNESS} if not supported_color_modes: @@ -1170,3 +1172,34 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: # noqa: E721 + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 962c5500f06..903002063e8 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2589,3 +2589,24 @@ def test_filter_supported_color_modes() -> None: # ColorMode.BRIGHTNESS has priority over ColorMode.ONOFF supported = {light.ColorMode.ONOFF, light.ColorMode.BRIGHTNESS} assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From bb6f3bc830d663ab02d4a014e8194382fdff238d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Dec 2023 10:04:16 +0100 Subject: [PATCH 866/927] Fix missing await when running shutdown jobs (#106632) --- homeassistant/core.py | 2 +- tests/test_core.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 72287fb81ce..51cb3d4e496 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -889,7 +889,7 @@ class HomeAssistant: continue tasks.append(task_or_none) if tasks: - asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks, return_exceptions=True) except asyncio.TimeoutError: _LOGGER.warning( "Timed out waiting for shutdown jobs to complete, the shutdown will" diff --git a/tests/test_core.py b/tests/test_core.py index 5f5be1b05db..90b87068a5d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2605,6 +2605,9 @@ async def test_shutdown_job(hass: HomeAssistant) -> None: evt = asyncio.Event() async def shutdown_func() -> None: + # Sleep to ensure core is waiting for the task to finish + await asyncio.sleep(0.01) + # Set the event evt.set() job = HassJob(shutdown_func, "shutdown_job") From c54af00ce98d616d1bd9f6cb932071108c03a6fc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Dec 2023 11:29:50 +0100 Subject: [PATCH 867/927] Bump version to 2024.1.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f2387299576..940a410a778 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index baa6814c75b..66e4c973339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b1" +version = "2024.1.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f9150b78b3877debc95de5955b0462494bd293b4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 31 Dec 2023 18:54:34 +0100 Subject: [PATCH 868/927] Ensure it's safe to call Entity.__repr__ on non added entity (#106032) --- homeassistant/components/sensor/__init__.py | 11 ----------- homeassistant/helpers/entity.py | 7 ++++++- tests/helpers/test_entity.py | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 5fca119d5b5..d7c5cddc5db 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -734,17 +734,6 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return value - def __repr__(self) -> str: - """Return the representation. - - Entity.__repr__ includes the state in the generated string, this fails if we're - called before self.hass is set. - """ - if not self.hass: - return f"" - - return super().__repr__() - def _suggested_precision_or_none(self) -> int | None: """Return suggested display precision, or None if not set.""" assert self.registry_entry diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index fc627f51acf..b7ed7e3c095 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1476,7 +1476,12 @@ class Entity( self.async_on_remove(self._async_unsubscribe_device_updates) def __repr__(self) -> str: - """Return the representation.""" + """Return the representation. + + If the entity is not added to a platform it's not safe to call _stringify_state. + """ + if self._platform_state != EntityPlatformState.ADDED: + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 96bbf95a986..4fca7ed4c23 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1392,8 +1392,8 @@ async def test_translation_key(hass: HomeAssistant) -> None: assert mock_entity2.translation_key == "from_entity_description" -async def test_repr_using_stringify_state() -> None: - """Test that repr uses stringify state.""" +async def test_repr(hass) -> None: + """Test Entity.__repr__.""" class MyEntity(MockEntity): """Mock entity.""" @@ -1403,9 +1403,20 @@ async def test_repr_using_stringify_state() -> None: """Return the state.""" raise ValueError("Boom") + platform = MockEntityPlatform(hass, domain="hello") my_entity = MyEntity(entity_id="test.test", available=False) + + # Not yet added + assert str(my_entity) == "" + + # Added + await platform.async_add_entities([my_entity]) assert str(my_entity) == "" + # Removed + await platform.async_remove_entity(my_entity.entity_id) + assert str(my_entity) == "" + async def test_warn_using_async_update_ha_state( hass: HomeAssistant, caplog: pytest.LogCaptureFixture From 3cd5f0568ac110e475631ec3e22668bec743f8f7 Mon Sep 17 00:00:00 2001 From: Jirka Date: Fri, 29 Dec 2023 12:01:23 +0100 Subject: [PATCH 869/927] Fix typo in Blink strings (#106641) Update strings.json Fixed typo. --- homeassistant/components/blink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 87e2fc68c20..a875fb3e343 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -106,7 +106,7 @@ }, "exceptions": { "integration_not_found": { - "message": "Integraion '{target}' not found in registry" + "message": "Integration '{target}' not found in registry" }, "no_path": { "message": "Can't write to directory {target}, no access to path!" From c1e37a4cc3125e24ea92fccbe698cee29018d1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hansl=C3=ADk?= Date: Fri, 29 Dec 2023 18:37:46 +0100 Subject: [PATCH 870/927] Fixed native apparent temperature in WeatherEntity (#106645) --- homeassistant/components/weather/__init__.py | 2 +- tests/components/smhi/snapshots/test_weather.ambr | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index fa832ca8c32..bdc8ae4d514 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -430,7 +430,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" - return self._attr_native_temperature + return self._attr_native_apparent_temperature @cached_property def native_temperature(self) -> float | None: diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index fa9d76c68ba..eb7378b5cba 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -669,7 +669,6 @@ # --- # name: test_setup_hass ReadOnlyDict({ - 'apparent_temperature': 18.0, 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'forecast': list([ From 5f3389b8e41138b4eb42aaa003471af8f59a897e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Dec 2023 13:22:06 +0100 Subject: [PATCH 871/927] Fix yolink entity descriptions (#106649) --- homeassistant/components/yolink/sensor.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4ac9379d763..ace13353341 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -48,21 +48,13 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass(frozen=True) -class YoLinkSensorEntityDescriptionMixin: - """Mixin for device type.""" - - exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - - -@dataclass(frozen=True) -class YoLinkSensorEntityDescription( - YoLinkSensorEntityDescriptionMixin, SensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class YoLinkSensorEntityDescription(SensorEntityDescription): """YoLink SensorEntityDescription.""" - value: Callable = lambda state: state + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True should_update_entity: Callable = lambda state: True + value: Callable = lambda state: state SENSOR_DEVICE_TYPE = [ From 767c55fbac207399603e94a890d5c5bf0b84feee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 29 Dec 2023 13:21:08 +0100 Subject: [PATCH 872/927] Use set instead of list in Systemmonitor (#106650) --- .../components/systemmonitor/config_flow.py | 2 +- .../components/systemmonitor/sensor.py | 14 +++++++------- homeassistant/components/systemmonitor/util.py | 18 +++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 3dc45480aee..6d9787a39f5 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -86,7 +86,7 @@ async def validate_import_sensor_setup( async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Return process sensor setup schema.""" hass = handler.parent_handler.hass - processes = await hass.async_add_executor_job(get_all_running_processes) + processes = list(await hass.async_add_executor_job(get_all_running_processes)) return vol.Schema( { vol.Required(CONF_PROCESS): SelectSelector( diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 2bc1406308c..28929d07a7c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -267,7 +267,7 @@ def check_required_arg(value: Any) -> Any: return value -def check_legacy_resource(resource: str, resources: list[str]) -> bool: +def check_legacy_resource(resource: str, resources: set[str]) -> bool: """Return True if legacy resource was configured.""" # This function to check legacy resources can be removed # once we are removing the import from YAML @@ -388,8 +388,8 @@ async def async_setup_entry( """Set up System Montor sensors based on a config entry.""" entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} - legacy_resources: list[str] = entry.options.get("resources", []) - loaded_resources: list[str] = [] + legacy_resources: set[str] = set(entry.options.get("resources", [])) + loaded_resources: set[str] = set() disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) @@ -405,7 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -425,7 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -449,7 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -463,7 +463,7 @@ async def async_setup_entry( sensor_registry[(_type, "")] = SensorData("", None, None, None, None) is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) - loaded_resources.append(f"{_type}_") + loaded_resources.add(f"{_type}_") entities.append( SystemMonitorSensor( sensor_registry, diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 27c4c449634..2baacb9d16f 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -8,9 +8,9 @@ import psutil _LOGGER = logging.getLogger(__name__) -def get_all_disk_mounts() -> list[str]: +def get_all_disk_mounts() -> set[str]: """Return all disk mount points on system.""" - disks: list[str] = [] + disks: set[str] = set() for part in psutil.disk_partitions(all=True): if os.name == "nt": if "cdrom" in part.opts or part.fstype == "": @@ -20,25 +20,25 @@ def get_all_disk_mounts() -> list[str]: continue usage = psutil.disk_usage(part.mountpoint) if usage.total > 0 and part.device != "": - disks.append(part.mountpoint) + disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) return disks -def get_all_network_interfaces() -> list[str]: +def get_all_network_interfaces() -> set[str]: """Return all network interfaces on system.""" - interfaces: list[str] = [] + interfaces: set[str] = set() for interface, _ in psutil.net_if_addrs().items(): - interfaces.append(interface) + interfaces.add(interface) _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) return interfaces -def get_all_running_processes() -> list[str]: +def get_all_running_processes() -> set[str]: """Return all running processes on system.""" - processes: list[str] = [] + processes: set[str] = set() for proc in psutil.process_iter(["name"]): if proc.name() not in processes: - processes.append(proc.name()) + processes.add(proc.name()) _LOGGER.debug("Running processes: %s", ", ".join(processes)) return processes From 494dd2ef0767ef93d1ad22f8640dfe8589cfadb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 29 Dec 2023 13:21:36 +0100 Subject: [PATCH 873/927] Handle no permission for disks in Systemmonitor (#106653) --- homeassistant/components/systemmonitor/util.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 2baacb9d16f..25b8aa2eb1d 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -18,7 +18,13 @@ def get_all_disk_mounts() -> set[str]: # ENOENT, pop-up a Windows GUI error for a non-ready # partition or just hang. continue - usage = psutil.disk_usage(part.mountpoint) + try: + usage = psutil.disk_usage(part.mountpoint) + except PermissionError: + _LOGGER.debug( + "No permission for running user to access %s", part.mountpoint + ) + continue if usage.total > 0 and part.device != "": disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) From 362e5ca09a63134288bcd633c39633834e20a1a8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 30 Dec 2023 08:34:21 +0100 Subject: [PATCH 874/927] Fix changed_variables in automation traces (#106665) --- homeassistant/helpers/script.py | 19 +++++++++---- homeassistant/helpers/trace.py | 26 ++++++++++------- tests/helpers/test_script.py | 49 ++++++++++++++------------------- 3 files changed, 50 insertions(+), 44 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a1d045eb542..07f10e13dbf 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy @@ -157,7 +157,12 @@ def action_trace_append(variables, path): @asynccontextmanager -async def trace_action(hass, script_run, stop, variables): +async def trace_action( + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Event, + variables: dict[str, Any], +) -> AsyncGenerator[TraceElement, None]: """Trace action execution.""" path = trace_path_get() trace_element = action_trace_append(variables, path) @@ -362,6 +367,8 @@ class _StopScript(_HaltScript): class _ScriptRun: """Manage Script sequence run.""" + _action: dict[str, Any] + def __init__( self, hass: HomeAssistant, @@ -376,7 +383,6 @@ class _ScriptRun: self._context = context self._log_exceptions = log_exceptions self._step = -1 - self._action: dict[str, Any] | None = None self._stop = asyncio.Event() self._stopped = asyncio.Event() @@ -446,11 +452,13 @@ class _ScriptRun: return ScriptRunResult(response, self._variables) - async def _async_step(self, log_exceptions): + async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): - async with trace_action(self._hass, self, self._stop, self._variables): + async with trace_action( + self._hass, self, self._stop, self._variables + ) as trace_element: if self._stop.is_set(): return @@ -466,6 +474,7 @@ class _ScriptRun: try: handler = f"_async_{action}_step" await getattr(self, handler)() + trace_element.update_variables(self._variables) except Exception as ex: # pylint: disable=broad-except self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index fd7a3081f7a..6c7d6cf0a7a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -21,6 +21,7 @@ class TraceElement: "_child_key", "_child_run_id", "_error", + "_last_variables", "path", "_result", "reuse_by_child", @@ -38,16 +39,8 @@ class TraceElement: self.reuse_by_child = False self._timestamp = dt_util.utcnow() - if variables is None: - variables = {} - last_variables = variables_cv.get() or {} - variables_cv.set(dict(variables)) - changed_variables = { - key: value - for key, value in variables.items() - if key not in last_variables or last_variables[key] != value - } - self._variables = changed_variables + self._last_variables = variables_cv.get() or {} + self.update_variables(variables) def __repr__(self) -> str: """Container for trace data.""" @@ -71,6 +64,19 @@ class TraceElement: old_result = self._result or {} self._result = {**old_result, **kwargs} + def update_variables(self, variables: TemplateVarsType) -> None: + """Update variables.""" + if variables is None: + variables = {} + last_variables = self._last_variables + variables_cv.set(dict(variables)) + changed_variables = { + key: value + for key, value in variables.items() + if key not in last_variables or last_variables[key] != value + } + self._variables = changed_variables + def as_dict(self) -> dict[str, Any]: """Return dictionary version of this TraceElement.""" result: dict[str, Any] = {"path": self.path, "timestamp": self._timestamp} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c2bad6287ab..1ea602f7cda 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -386,7 +386,10 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - } + }, + "variables": { + "my_response": {"data": "value-12345"}, + }, } ], "1": [ @@ -399,10 +402,7 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - }, - "variables": { - "my_response": {"data": "value-12345"}, - }, + } } ], } @@ -1163,13 +1163,13 @@ async def test_wait_template_not_schedule(hass: HomeAssistant) -> None: assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {"wait": {"completed": True, "remaining": None}}}], - "2": [ + "1": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": {"wait": {"completed": True, "remaining": None}}, "variables": {"wait": {"completed": True, "remaining": None}}, } ], + "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) @@ -1230,13 +1230,13 @@ async def test_wait_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], - "1": [ + "0": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": variable_wait, "variables": variable_wait, } ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], } assert_action_trace(expected_trace) @@ -1291,19 +1291,14 @@ async def test_wait_continue_on_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], + "0": [{"result": variable_wait, "variables": variable_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error_type"] = asyncio.TimeoutError expected_script_execution = "aborted" else: - expected_trace["1"] = [ - { - "result": {"event": "test_event", "event_data": {}}, - "variables": variable_wait, - } - ] + expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) @@ -3269,12 +3264,12 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - "description": "state of switch.trigger", }, } - } + }, + "variables": {"wait": {"remaining": None}}, } ], "0/parallel/1/sequence/0": [ { - "variables": {}, "result": { "event": "test_event", "event_data": {"hello": "from action 2", "what": "world"}, @@ -3283,7 +3278,6 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - ], "0/parallel/0/sequence/1": [ { - "variables": {"wait": {"remaining": None}}, "result": { "event": "test_event", "event_data": {"hello": "from action 1", "what": "world"}, @@ -4462,7 +4456,7 @@ async def test_set_variable( assert f"Executing step {alias}" in caplog.text expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "value"}}], "1": [ { "result": { @@ -4474,7 +4468,6 @@ async def test_set_variable( }, "running_script": False, }, - "variables": {"variable": "value"}, } ], } @@ -4504,7 +4497,7 @@ async def test_set_redefines_variable( assert mock_calls[1].data["value"] == 2 expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "1"}}], "1": [ { "result": { @@ -4515,11 +4508,10 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": "1"}, + } } ], - "2": [{}], + "2": [{"variables": {"variable": 2}}], "3": [ { "result": { @@ -4530,8 +4522,7 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": 2}, + } } ], } From 84da1638e85c7a37333b4765a7661e8de750e08c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 23:33:59 -1000 Subject: [PATCH 875/927] Bump thermobeacon-ble to 0.6.2 (#106676) changelog: https://github.com/Bluetooth-Devices/thermobeacon-ble/compare/v0.6.0...v0.6.2 --- homeassistant/components/thermobeacon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 772c565e9d2..29443acaa3d 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -42,5 +42,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.6.0"] + "requirements": ["thermobeacon-ble==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e22f7a0815..f1df817bc7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2635,7 +2635,7 @@ tessie-api==0.0.9 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4360fc394c7..4c57d9cfd9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ tesla-wall-connector==1.0.2 tessie-api==0.0.9 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 From 3dd998b622665f1ea605a6c1536a31336239c4c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 13:45:27 -1000 Subject: [PATCH 876/927] Bump roombapy to 1.6.10 (#106678) changelog: https://github.com/pschmitt/roombapy/compare/1.6.8...1.6.10 fixes #105323 --- homeassistant/components/roomba/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 8e6b92732eb..fbe6c925438 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"], + "requirements": ["roombapy==1.6.10"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f1df817bc7f..21226355623 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2400,7 +2400,7 @@ rocketchat-API==0.6.1 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon roonapi==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c57d9cfd9f..7906924b1cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1807,7 +1807,7 @@ ring-doorbell[listen]==0.8.5 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon roonapi==0.1.6 From 8dfbe6849e0dcfb4566d5352757af3ed8d10ecfd Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 29 Dec 2023 19:45:04 -0500 Subject: [PATCH 877/927] Bump asyncsleepiq to v1.4.1 (#106682) Update asyncsleepiq to v1.4.1 --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index d58c20b14b8..62bd3930c77 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.4.0"] + "requirements": ["asyncsleepiq==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21226355623..13d2597420f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -478,7 +478,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.4.0 +asyncsleepiq==1.4.1 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7906924b1cc..4a6ba2b0d1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -424,7 +424,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.38.0 # homeassistant.components.sleepiq -asyncsleepiq==1.4.0 +asyncsleepiq==1.4.1 # homeassistant.components.aurora auroranoaa==0.0.3 From 456cb20fcd0bda094552a7b1b5f16671de4f9613 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 14:12:19 -1000 Subject: [PATCH 878/927] Fix missed cached_property for hvac_mode in climate (#106692) --- homeassistant/components/climate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 19e26265f70..78cb92944cb 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -227,6 +227,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "temperature_unit", "current_humidity", "target_humidity", + "hvac_mode", "hvac_modes", "hvac_action", "current_temperature", @@ -414,7 +415,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode From 2255f6737c4a14a42351bca6f9b5f06770a8c580 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Dec 2023 00:29:19 -1000 Subject: [PATCH 879/927] Pin lxml to 4.9.4 (#106694) --- homeassistant/components/scrape/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 4 ++++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 26603603198..708ecc14d16 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a6c59c98dc0..a3d84b8ce08 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -185,3 +185,7 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index 13d2597420f..b1ffc9d5e40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1225,7 +1225,7 @@ lupupy==0.3.1 lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a6ba2b0d1f..c32d93814d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -958,7 +958,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bcd19b97e08..101c9294706 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,10 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 """ GENERATED_MESSAGE = ( From 2179d4de3d0c6d979fdb5e10e8f4d48d756ef137 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 23:16:06 +0100 Subject: [PATCH 880/927] Add missing vacuum toggle service description (#106729) --- homeassistant/components/vacuum/services.yaml | 8 ++++++++ homeassistant/components/vacuum/strings.json | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index aab35b42077..25f3822bd35 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -14,6 +14,14 @@ turn_off: supported_features: - vacuum.VacuumEntityFeature.TURN_OFF +toggle: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_OFF + - vacuum.VacuumEntityFeature.TURN_ON + stop: target: entity: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 3c018fc1a89..15ba2076060 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -48,6 +48,10 @@ "name": "[%key:common::action::turn_off%]", "description": "Stops the current cleaning task and returns to its dock." }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the vacuum cleaner on/off." + }, "stop": { "name": "[%key:common::action::stop%]", "description": "Stops the current cleaning task." From 3d75603b4f6cfbf06252a1bcfbe955e514f49823 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 31 Dec 2023 12:12:06 -0500 Subject: [PATCH 881/927] Fix Zlinky energy polling in ZHA (#106738) --- .../components/zha/core/cluster_handlers/smartenergy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 8fd38425dff..2ceaeaf1013 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -195,9 +195,9 @@ class Metering(ClusterHandler): ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) - async def async_force_update(self) -> None: + async def async_update(self) -> None: """Retrieve latest state.""" - self.debug("async_force_update") + self.debug("async_update") attrs = [ a["attr"] From 05768f5fbd380d184277ce773b048aff4f4a2c15 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 31 Dec 2023 00:45:05 +0100 Subject: [PATCH 882/927] Bump reolink_aio to 0.8.5 (#106747) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/media_source.py | 11 ++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index e687fc5d9b1..d5116af0071 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.8.4"] + "requirements": ["reolink-aio==0.8.5"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 6a350e13836..2a1eee9e97d 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -5,6 +5,8 @@ from __future__ import annotations import datetime as dt import logging +from reolink_aio.enums import VodRequestType + from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source.error import Unresolvable @@ -56,7 +58,14 @@ class ReolinkVODMediaSource(MediaSource): channel = int(channel_str) host = self.data[config_entry_id].host - mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + + vod_type = VodRequestType.RTMP + if host.api.is_nvr: + vod_type = VodRequestType.FLV + + mime_type, url = await host.api.get_vod_source( + channel, filename, stream_res, vod_type + ) if _LOGGER.isEnabledFor(logging.DEBUG): url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index b1ffc9d5e40..839fddddebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2376,7 +2376,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c32d93814d7..b7d7650691d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.rflink rflink==0.0.65 From a11fd2aaa665b186d80ed8503bdbd890c1036621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Dec 2023 22:44:12 -1000 Subject: [PATCH 883/927] Bump pyunifiprotect to 4.22.4 (#106749) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.22.3...v4.22.4 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index cd38f50bf6d..c74097c3c17 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.4", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 839fddddebd..ed18f3659f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2280,7 +2280,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7d7650691d..77abafcfc66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1723,7 +1723,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 3dca39d0f9c4cadb880039409f9bf9a847df1854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 31 Dec 2023 06:44:55 -1000 Subject: [PATCH 884/927] Bump habluetooth to 2.0.1 (#106750) fixes switching scanners to quickly since the manager failed to account for jitter in the auto discovered advertising interval replaces and closes #96531 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v2.0.0...v2.0.1 --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 86 ++++++++++++++++++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 33404a762b9..19199e4b1c6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.0.0" + "habluetooth==2.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a3d84b8ce08..842bff950fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.0.0 +habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index ed18f3659f3..f9111763365 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.0 +habluetooth==2.0.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77abafcfc66..73f636b5692 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -800,7 +800,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.0 +habluetooth==2.0.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 212f45bb5f0..4726c12f681 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -7,11 +7,12 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory -from habluetooth.manager import FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, @@ -315,6 +316,89 @@ async def test_switching_adapters_based_on_stale( ) +async def test_switching_adapters_based_on_stale_with_discovered_interval( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test switching with discovered interval.""" + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + bluetooth.async_set_fallback_availability_interval(hass, address, 10) + + switchbot_device_poor_signal_hci1 = generate_ble_device( + address, "wohand_poor_signal_hci1" + ) + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic, + "hci1", + ) + + # Should not switch adapters until the advertisement is stale + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + 1, + "hci1", + ) + + # Should not switch yet since we are not within the + # wobble period + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, + "hci1", + ) + # Should switch to hci1 since the previous advertisement is stale + # even though the signal is poor because the device is now + # likely unreachable via hci0 + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci1 + ) + + async def test_restore_history_from_dbus( hass: HomeAssistant, one_adapter: None, disable_new_discovery_flows ) -> None: From 99d575261d260a03d4d1e2409cdbc84d627bcc79 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 31 Dec 2023 04:54:09 -0500 Subject: [PATCH 885/927] Bump ZHA dependencies (#106756) * Bump ZHA dependencies * Revert "Remove bellows thread, as it has been removed upstream" This reverts commit c28053f4bf2539eb6150d35af19687610aaeac5e. --- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 10 +++++ homeassistant/components/zha/manifest.json | 6 +-- homeassistant/components/zha/radio_manager.py | 2 + requirements_all.txt | 6 +-- requirements_test_all.txt | 6 +-- tests/components/zha/test_gateway.py | 45 ++++++++++++++++++- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 7e591a596e5..ecbd347a621 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,6 +139,7 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" +CONF_USE_THREAD = "use_thread" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1308abb3d37..12e439f1059 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,6 +46,7 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_RADIO_TYPE, + CONF_USE_THREAD, CONF_ZIGPY, DATA_ZHA, DEBUG_COMP_BELLOWS, @@ -158,6 +159,15 @@ class ZHAGateway: if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True + # The bellows UART thread sometimes propagates a cancellation into the main Core + # event loop, when a connection to a TCP coordinator fails in a specific way + if ( + CONF_USE_THREAD not in app_config + and radio_type is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6a14a3064a6..db5939123e4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,12 +21,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.4", + "bellows==0.37.6", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.109", - "zigpy-deconz==0.22.3", - "zigpy==0.60.2", + "zigpy-deconz==0.22.4", + "zigpy==0.60.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 92a90e0e13a..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -10,6 +10,7 @@ import logging import os from typing import Any, Self +from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -174,6 +175,7 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False + app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( diff --git a/requirements_all.txt b/requirements_all.txt index f9111763365..b4d0c6f304f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2875,7 +2875,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2887,7 +2887,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.3 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73f636b5692..8d7c57932d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2171,7 +2171,7 @@ zeversolar==0.3.1 zha-quirks==0.0.109 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2183,7 +2183,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.2 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 1d9042daa4a..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,8 +1,9 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting @@ -222,6 +223,48 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", + MagicMock(), +) +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", + MagicMock(), +) +@pytest.mark.parametrize( + ("device_path", "thread_state", "config_override"), + [ + ("/dev/ttyUSB0", True, {}), + ("socket://192.168.1.123:9999", False, {}), + ("socket://192.168.1.123:9999", True, {"use_thread": True}), + ], +) +async def test_gateway_initialize_bellows_thread( + device_path: str, + thread_state: bool, + config_override: dict, + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) + + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() + + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() + + @pytest.mark.parametrize( ("device_path", "config_override", "expected_channel"), [ From c06df1957fe0c88ceba85c3be9ef57e392ea2a34 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 31 Dec 2023 10:04:42 +0100 Subject: [PATCH 886/927] Bump pyatmo to v8.0.2 (#106758) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index f5f2d67947f..aee63e60016 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.1"] + "requirements": ["pyatmo==8.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4d0c6f304f..587fa1ac599 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1648,7 +1648,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d7c57932d3..0f6ca00186e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1265,7 +1265,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 From a7d11120fadd8e89633a324a692f689fa02defc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 31 Dec 2023 18:57:11 +0100 Subject: [PATCH 887/927] Bump version to 2024.1.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 940a410a778..87f6f9fd7d0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 66e4c973339..8976143bf0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b2" +version = "2024.1.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 77286e8f596e5edd2915dfc7a400611872cd9b0d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 1 Jan 2024 23:00:17 -0500 Subject: [PATCH 888/927] Constrain dacite to at least 1.7.0 (#105709) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 842bff950fc..eb264561000 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -189,3 +189,7 @@ charset-normalizer==3.2.0 # lxml 5.0.0 currently does not build on alpine 3.18 # https://bugs.launchpad.net/lxml/+bug/2047718 lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101c9294706..3cecff68fb0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -183,6 +183,10 @@ charset-normalizer==3.2.0 # lxml 5.0.0 currently does not build on alpine 3.18 # https://bugs.launchpad.net/lxml/+bug/2047718 lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 """ GENERATED_MESSAGE = ( From fedb63720cf456557eafd1e8c3ce7135bd1c4942 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 2 Jan 2024 06:46:39 -0500 Subject: [PATCH 889/927] Fix Hydrawise data not refreshing (#105923) Co-authored-by: Robert Resch --- .../components/hydrawise/binary_sensor.py | 2 +- .../components/hydrawise/coordinator.py | 27 ++++++++++++++++--- homeassistant/components/hydrawise/entity.py | 3 +++ homeassistant/components/hydrawise/sensor.py | 2 +- homeassistant/components/hydrawise/switch.py | 2 +- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 65355a1829f..0b12fcb3ddb 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -70,7 +70,7 @@ async def async_setup_entry( config_entry.entry_id ] entities = [] - for controller in coordinator.data.controllers: + for controller in coordinator.data.controllers.values(): entities.append( HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) ) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 412108f859f..71922928651 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from pydrawise import HydrawiseBase -from pydrawise.schema import User +from pydrawise.schema import Controller, User, Zone from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -13,9 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): +@dataclass +class HydrawiseData: + """Container for data fetched from the Hydrawise API.""" + + user: User + controllers: dict[int, Controller] + zones: dict[int, Zone] + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """The Hydrawise Data Update Coordinator.""" + api: HydrawiseBase + def __init__( self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta ) -> None: @@ -23,6 +35,13 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) self.api = api - async def _async_update_data(self) -> User: + async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" - return await self.api.get_user() + user = await self.api.get_user() + controllers = {} + zones = {} + for controller in user.controllers: + controllers[controller.id] = controller + for zone in controller.zones: + zones[zone.id] = zone + return HydrawiseData(user=user, controllers=controllers, zones=zones) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c707690ce95..887de6ba648 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -48,5 +48,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + self.controller = self.coordinator.data.controllers[self.controller.id] + if self.zone: + self.zone = self.coordinator.data.zones[self.zone.id] self._update_attrs() super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 79a318f778f..f8490ad00e1 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -76,7 +76,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSensor(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SENSOR_TYPES ) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 5a3a3a62895..8a92a56975a 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -81,7 +81,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSwitch(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SWITCH_TYPES ) From e6d2721d1b01d206dfd0c817fb6800e3c7b60bff Mon Sep 17 00:00:00 2001 From: Benjamin Richter Date: Tue, 2 Jan 2024 09:59:13 +0100 Subject: [PATCH 890/927] Fix fints account type check (#106082) --- homeassistant/components/fints/sensor.py | 8 +- requirements_test_all.txt | 3 + tests/components/fints/__init__.py | 1 + tests/components/fints/test_client.py | 95 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/components/fints/__init__.py create mode 100644 tests/components/fints/test_client.py diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index fafe1fcf2bf..c969adfe637 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,8 +168,8 @@ class FinTsClient: if not account_information: return False - if 1 <= account_information["type"] <= 9: - return True + if account_type := account_information.get("type"): + return 1 <= account_type <= 9 if ( account_information["iban"] in self.account_config @@ -188,8 +188,8 @@ class FinTsClient: if not account_information: return False - if 30 <= account_information["type"] <= 39: - return True + if account_type := account_information.get("type"): + return 30 <= account_type <= 39 if ( account_information["iban"] in self.holdings_config diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f6ca00186e..0603750b0d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -659,6 +659,9 @@ feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fints +fints==3.1.0 + # homeassistant.components.fitbit fitbit==0.3.1 diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py new file mode 100644 index 00000000000..6a2b1d96d20 --- /dev/null +++ b/tests/components/fints/__init__.py @@ -0,0 +1 @@ +"""Tests for FinTS component.""" diff --git a/tests/components/fints/test_client.py b/tests/components/fints/test_client.py new file mode 100644 index 00000000000..429d391b07e --- /dev/null +++ b/tests/components/fints/test_client.py @@ -0,0 +1,95 @@ +"""Tests for the FinTS client.""" + +from typing import Optional + +from fints.client import BankIdentifier, FinTSOperations +import pytest + +from homeassistant.components.fints.sensor import ( + BankCredentials, + FinTsClient, + SEPAAccount, +) + +BANK_INFORMATION = { + "bank_identifier": BankIdentifier(country_identifier="280", bank_code="50010517"), + "currency": "EUR", + "customer_id": "0815", + "owner_name": ["SURNAME, FIRSTNAME"], + "subaccount_number": None, + "supported_operations": { + FinTSOperations.GET_BALANCE: True, + FinTSOperations.GET_CREDIT_CARD_TRANSACTIONS: False, + FinTSOperations.GET_HOLDINGS: False, + FinTSOperations.GET_SCHEDULED_DEBITS_MULTIPLE: False, + FinTSOperations.GET_SCHEDULED_DEBITS_SINGLE: False, + FinTSOperations.GET_SEPA_ACCOUNTS: True, + FinTSOperations.GET_STATEMENT: False, + FinTSOperations.GET_STATEMENT_PDF: False, + FinTSOperations.GET_TRANSACTIONS: True, + FinTSOperations.GET_TRANSACTIONS_XML: False, + }, +} + + +@pytest.mark.parametrize( + ( + "account_number", + "iban", + "product_name", + "account_type", + "expected_balance_result", + "expected_holdings_result", + ), + [ + ("GIRO1", "GIRO1", "Valid balance account", 5, True, False), + (None, None, "Invalid account", None, False, False), + ("GIRO2", "GIRO2", "Account without type", None, False, False), + ("GIRO3", "GIRO3", "Balance account from fallback", None, True, False), + ("DEPOT1", "DEPOT1", "Valid holdings account", 33, False, True), + ("DEPOT2", "DEPOT2", "Holdings account from fallback", None, False, True), + ], +) +async def test_account_type( + account_number: Optional[str], + iban: Optional[str], + product_name: str, + account_type: Optional[int], + expected_balance_result: bool, + expected_holdings_result: bool, +) -> None: + """Check client methods is_balance_account and is_holdings_account.""" + credentials = BankCredentials( + blz=1234, login="test", pin="0000", url="https://example.com" + ) + account_config = {"GIRO3": True} + holdings_config = {"DEPOT2": True} + + client = FinTsClient( + credentials=credentials, + name="test", + account_config=account_config, + holdings_config=holdings_config, + ) + + client._account_information_fetched = True + client._account_information = { + iban: BANK_INFORMATION + | { + "account_number": account_number, + "iban": iban, + "product_name": product_name, + "type": account_type, + } + } + + sepa_account = SEPAAccount( + iban=iban, + bic="BANCODELTEST", + accountnumber=account_number, + subaccount=None, + blz="12345", + ) + + assert client.is_balance_account(sepa_account) == expected_balance_result + assert client.is_holdings_account(sepa_account) == expected_holdings_result From 39960caf36abd935f82f11003f3ca73e7d5d4b0a Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sun, 31 Dec 2023 14:26:21 -0500 Subject: [PATCH 891/927] Bump pyunifiprotect to v4.22.5 (#106781) --- .../components/unifiprotect/binary_sensor.py | 11 +++++++++++ homeassistant/components/unifiprotect/button.py | 7 +++++++ homeassistant/components/unifiprotect/data.py | 2 ++ homeassistant/components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/media_player.py | 11 +++++++++++ homeassistant/components/unifiprotect/number.py | 12 ++++++++++++ homeassistant/components/unifiprotect/select.py | 11 +++++++++++ homeassistant/components/unifiprotect/sensor.py | 9 +++++++++ homeassistant/components/unifiprotect/switch.py | 11 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 11 files changed, 77 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index f32b53a5d7a..1104ecb98e1 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -643,4 +643,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): or self._attr_extra_state_attributes != previous_extra_state_attributes or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_is_on, + previous_available, + previous_extra_state_attributes, + self._attr_is_on, + self._attr_available, + self._attr_extra_state_attributes, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 01bde0d9248..b69fbb95970 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -206,4 +206,11 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): previous_available = self._attr_available self._async_update_device_from_protect(device) if self._attr_available != previous_available: + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device.name, + device.mac, + previous_available, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 73d05f1be1d..8b8ec80c5ba 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -228,6 +228,8 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): # type: ignore[unreachable] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("event WS msg: %s", obj.dict()) if obj.type in SMART_EVENTS: if obj.camera is not None: if obj.end is None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index c74097c3c17..2fbf8f31071 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.4", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index df5ea40d4a9..b2376277e6f 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -133,6 +133,17 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): or self._attr_volume_level != previous_volume_level or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_state, + previous_available, + previous_volume_level, + self._attr_state, + self._attr_available, + self._attr_volume_level, + ) self.async_write_ha_state() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 7fed79499d2..c02753a9401 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from pyunifiprotect.data import ( Camera, @@ -25,6 +26,8 @@ from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True) class NumberKeysMixin: @@ -285,4 +288,13 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 649c77bed5b..dfc3be2d4a1 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -420,4 +420,15 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): or self._attr_options != previous_options or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_option, + previous_available, + previous_options, + self._attr_current_option, + self._attr_available, + self._attr_options, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 6344b852b63..3e2bd6ee858 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -730,6 +730,15 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index c57546be8d0..d8a3fc1c5bc 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Any from pyunifiprotect.data import ( @@ -27,6 +28,7 @@ from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_enti from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" @@ -458,6 +460,15 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): self._attr_is_on != previous_is_on or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_is_on, + previous_available, + self._attr_is_on, + self._attr_available, + ) self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 587fa1ac599..bf0bbf67f8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2280,7 +2280,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.4 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0603750b0d9..1cf739dd4f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1726,7 +1726,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.4 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 16d3d88fa3dfb5efb3593bbc01eb9a560231fe84 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 31 Dec 2023 14:17:51 -0500 Subject: [PATCH 892/927] Bump pyschlage to 2023.12.1 (#106782) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index e14a5bc706e..72d5ad54565 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.12.0"] + "requirements": ["pyschlage==2023.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf0bbf67f8f..9a34d99b55d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2058,7 +2058,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1cf739dd4f2..dfef509ec99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1567,7 +1567,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 From b1a55e9b19a1ceb58093c467d24016f0cf94003a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:51:05 -1000 Subject: [PATCH 893/927] Fix emulated_hue brightness check (#106783) --- .../components/emulated_hue/hue_api.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 05e5c1ece07..0730eced60c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from functools import lru_cache import hashlib from http import HTTPStatus @@ -41,6 +42,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + ColorMode, LightEntityFeature, ) from homeassistant.components.media_player import ( @@ -115,12 +117,19 @@ UNAUTHORIZED_USER = [ {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} ] -DIMMABLE_SUPPORT_FEATURES = ( - CoverEntityFeature.SET_POSITION - | FanEntityFeature.SET_SPEED - | MediaPlayerEntityFeature.VOLUME_SET - | ClimateEntityFeature.TARGET_TEMPERATURE -) +DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + fan.DOMAIN: FanEntityFeature.SET_SPEED, + media_player.DOMAIN: MediaPlayerEntityFeature.VOLUME_SET, + climate.DOMAIN: ClimateEntityFeature.TARGET_TEMPERATURE, +} + +ENTITY_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature, + fan.DOMAIN: FanEntityFeature, + media_player.DOMAIN: MediaPlayerEntityFeature, + climate.DOMAIN: ClimateEntityFeature, +} @lru_cache(maxsize=32) @@ -756,7 +765,6 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -773,9 +781,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "manufacturername": "Home Assistant", "swversion": "123", } - - color_supported = light.color_supported(color_modes) - color_temp_supported = light.color_temp_supported(color_modes) + is_light = state.domain == light.DOMAIN + color_supported = is_light and light.color_supported(color_modes) + color_temp_supported = is_light and light.color_temp_supported(color_modes) if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature @@ -820,9 +828,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) - elif entity_features & DIMMABLE_SUPPORT_FEATURES or light.brightness_supported( - color_modes - ): + elif state_supports_hue_brightness(state, color_modes): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" @@ -845,6 +851,21 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: return retval +def state_supports_hue_brightness( + state: State, color_modes: Iterable[ColorMode] +) -> bool: + """Return True if the state supports brightness.""" + domain = state.domain + if domain == light.DOMAIN: + return light.brightness_supported(color_modes) + if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): + return False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + enum = ENTITY_FEATURES_BY_DOMAIN[domain] + features = enum(features) if type(features) is int else features # noqa: E721 + return required_feature in features + + def create_hue_success_response( entity_number: str, attr: str, value: str ) -> dict[str, Any]: From 6ca3c7a6733e64d18090b06ab09b2aa2cac2464a Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 1 Jan 2024 23:45:31 +0100 Subject: [PATCH 894/927] Bump pyduotecno to 2024.1.1 (#106801) * Bump pyduotecno to 2024.0.1 * Bump pyduotecno to 2024.1.0 * small update --- homeassistant/components/duotecno/climate.py | 2 +- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 8e23e742c04..dc10e0a61d9 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -52,7 +52,7 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_translation_key = "duotecno" @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Get the current temperature.""" return self._unit.get_cur_temp() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 2f221929178..9f6d082cae8 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.11.1"] + "requirements": ["pyDuotecno==2024.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a34d99b55d..803212d03b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1599,7 +1599,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfef509ec99..67b3eeb8e1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1234,7 +1234,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 From 448e98eac59003f98fe1b86449595adb979f3732 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Jan 2024 05:15:34 +0100 Subject: [PATCH 895/927] Update frontend to 20240101.0 (#106808) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 227fa96edf7..02a311a42ce 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231228.0"] + "requirements": ["home-assistant-frontend==20240101.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eb264561000..e1166ed5f0a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 803212d03b6..c5031a97cbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67b3eeb8e1c..1b76d8bc191 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 54a87cf04759827427b6e5b0e06fdbe3b1327775 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jan 2024 12:16:17 -1000 Subject: [PATCH 896/927] Bump bleak-retry-connector to 3.4.0 (#106831) --- 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 19199e4b1c6..c5dec12fe40 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.3.0", + "bleak-retry-connector==3.4.0", "bluetooth-adapters==0.16.2", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e1166ed5f0a..4f63f79aac5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 bcrypt==4.0.1 -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 bleak==0.21.1 bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index c5031a97cbc..71520486027 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,7 +538,7 @@ bizkaibus==0.1.1 bleak-esphome==0.4.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b76d8bc191..42649400e4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -457,7 +457,7 @@ bimmer-connected[china]==0.14.6 bleak-esphome==0.4.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 From 38b8a1f95d06f01f4450332918ee2289ef659b17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:39:04 -1000 Subject: [PATCH 897/927] Bump pySwitchbot to 0.43.0 (#106833) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e835a2f4aca..d3d84d2cd48 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.1"] + "requirements": ["PySwitchbot==0.43.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71520486027..ba7221b554c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42649400e4a..020678ecc8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 8c25e2610e87237fa2de04deae09b8093920dd54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:41:56 -1000 Subject: [PATCH 898/927] Bump yalexs-ble to 2.4.0 (#106834) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index aacebb4bb5c..d0f2a27522d 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index be388ec563c..dcd7e57ce1f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.2"] + "requirements": ["yalexs-ble==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba7221b554c..1584b61146f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2830,7 +2830,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 020678ecc8b..0ce1394a889 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2141,7 +2141,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 From 59bed57d482358c36de94325e71ff7264e43155f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:37:51 -1000 Subject: [PATCH 899/927] Fix incorrect state in Yale Access Bluetooth when lock status is unknown (#106851) --- homeassistant/components/yalexs_ble/lock.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index d457784a038..f6fa1917d7e 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -40,17 +40,19 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): self._attr_is_unlocking = False self._attr_is_jammed = False lock_state = new_state.lock - if lock_state == LockStatus.LOCKED: + if lock_state is LockStatus.LOCKED: self._attr_is_locked = True - elif lock_state == LockStatus.LOCKING: + elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True - elif lock_state == LockStatus.UNLOCKING: + elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, ): self._attr_is_jammed = True + elif lock_state is LockStatus.UNKNOWN: + self._attr_is_locked = None super()._async_update_state(new_state, lock_info, connection_info) async def async_unlock(self, **kwargs: Any) -> None: From e604bc8c9b54078174a43095baa7178ba78571fb Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Mon, 1 Jan 2024 19:58:12 -0800 Subject: [PATCH 900/927] Map missing preset mapping for heat mode "ready" in smarttub (#106856) --- homeassistant/components/smarttub/climate.py | 2 ++ tests/components/smarttub/test_climate.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index b2d4fbf17c4..9f1802e7327 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -23,11 +23,13 @@ from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLL from .entity import SmartTubEntity PRESET_DAY = "day" +PRESET_READY = "ready" PRESET_MODES = { Spa.HeatMode.AUTO: PRESET_NONE, Spa.HeatMode.ECONOMY: PRESET_ECO, Spa.HeatMode.DAY: PRESET_DAY, + Spa.HeatMode.READY: PRESET_READY, } HEAT_MODES = {v: k for k, v in PRESET_MODES.items()} diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index 601015ca681..40e3c05b509 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -58,7 +58,7 @@ async def test_thermostat_update( assert state.attributes[ATTR_TEMPERATURE] == 39 assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day"] + assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day", "ready"] await hass.services.async_call( CLIMATE_DOMAIN, From 056b06de13caa79e894043bb142adda01b5fbf0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 11:35:16 +0100 Subject: [PATCH 901/927] Don't use entity_id in __repr__ of not added entity (#106861) --- homeassistant/helpers/entity.py | 2 +- tests/helpers/test_entity.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b7ed7e3c095..3c3c8474e67 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1481,7 +1481,7 @@ class Entity( If the entity is not added to a platform it's not safe to call _stringify_state. """ if self._platform_state != EntityPlatformState.ADDED: - return f"" + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4fca7ed4c23..a18d8963947 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1407,7 +1407,7 @@ async def test_repr(hass) -> None: my_entity = MyEntity(entity_id="test.test", available=False) # Not yet added - assert str(my_entity) == "" + assert str(my_entity) == "" # Added await platform.async_add_entities([my_entity]) @@ -1415,7 +1415,7 @@ async def test_repr(hass) -> None: # Removed await platform.async_remove_entity(my_entity.entity_id) - assert str(my_entity) == "" + assert str(my_entity) == "" async def test_warn_using_async_update_ha_state( From fc66dead64413b807d0f6ba08cbfb045ae6e7c51 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 2 Jan 2024 12:59:23 +0100 Subject: [PATCH 902/927] Bump version to 2024.1.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 87f6f9fd7d0..525c2db95ca 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 8976143bf0d..c7f9622faa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b3" +version = "2024.1.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 35fc26457b116a98968494bc448e414ab252286a Mon Sep 17 00:00:00 2001 From: Robert Groot <8398505+iamrgroot@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:24:17 +0100 Subject: [PATCH 903/927] Changed setup of EnergyZero services (#106224) * Changed setup of energyzero services * PR review updates * Dict access instead of get Co-authored-by: Martin Hjelmare * Added tests for unloaded state --------- Co-authored-by: Martin Hjelmare --- .../components/energyzero/__init__.py | 15 +++- .../components/energyzero/services.py | 49 +++++++++-- .../components/energyzero/services.yaml | 10 +++ .../components/energyzero/strings.json | 14 ++++ tests/components/energyzero/test_services.py | 82 +++++++++++++++++-- 5 files changed, 155 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 0eac874f1ed..8878a99e562 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -5,12 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator -from .services import async_register_services +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up EnergyZero services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -27,8 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_register_services(hass, coordinator) - return True diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index fb451c40401..d8e548c22f8 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -9,6 +9,7 @@ from typing import Final from energyzero import Electricity, Gas, VatOption import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,11 +18,13 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator +ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" ATTR_END: Final = "end" ATTR_INCL_VAT: Final = "incl_vat" @@ -30,6 +33,11 @@ GAS_SERVICE_NAME: Final = "get_gas_prices" ENERGY_SERVICE_NAME: Final = "get_energy_prices" SERVICE_SCHEMA: Final = vol.Schema( { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), vol.Required(ATTR_INCL_VAT): bool, vol.Optional(ATTR_START): str, vol.Optional(ATTR_END): str, @@ -75,12 +83,43 @@ def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: } +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EnergyZeroDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + async def __get_prices( call: ServiceCall, *, - coordinator: EnergyZeroDataUpdateCoordinator, + hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: + coordinator = __get_coordinator(hass, call) + start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -108,22 +147,20 @@ async def __get_prices( @callback -def async_register_services( - hass: HomeAssistant, coordinator: EnergyZeroDataUpdateCoordinator -): +def async_setup_services(hass: HomeAssistant) -> None: """Set up EnergyZero services.""" hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, coordinator=coordinator, price_type=PriceType.GAS), + partial(__get_prices, hass=hass, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_SERVICE_NAME, - partial(__get_prices, coordinator=coordinator, price_type=PriceType.ENERGY), + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml index 1bcc5ae34be..dc8df9aa6d0 100644 --- a/homeassistant/components/energyzero/services.yaml +++ b/homeassistant/components/energyzero/services.yaml @@ -1,5 +1,10 @@ get_gas_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero incl_vat: required: true default: true @@ -17,6 +22,11 @@ get_gas_prices: datetime: get_energy_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero incl_vat: required: true default: true diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 81f54f4222a..9858838aff7 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -12,6 +12,12 @@ "exceptions": { "invalid_date": { "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." } }, "entity": { @@ -50,6 +56,10 @@ "name": "Get gas prices", "description": "Request gas prices from EnergyZero.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "incl_vat": { "name": "Including VAT", "description": "Include VAT in the prices." @@ -68,6 +78,10 @@ "name": "Get energy prices", "description": "Request energy prices from EnergyZero.", "fields": { + "config_entry": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::description%]" + }, "incl_vat": { "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]", "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]" diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index 7939b06ce8e..c0b54729e03 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -6,12 +6,15 @@ import voluptuous as vol from homeassistant.components.energyzero.const import DOMAIN from homeassistant.components.energyzero.services import ( + ATTR_CONFIG_ENTRY, ENERGY_SERVICE_NAME, GAS_SERVICE_NAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("init_integration") async def test_has_services( @@ -29,6 +32,7 @@ async def test_has_services( @pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) async def test_service( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, service: str, incl_vat: dict[str, bool], @@ -36,8 +40,9 @@ async def test_service( end: dict[str, str], ) -> None: """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} - data = incl_vat | start | end + data = entry | incl_vat | start | end assert snapshot == await hass.services.async_call( DOMAIN, @@ -48,32 +53,72 @@ async def test_service( ) +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) @pytest.mark.parametrize( - ("service_data", "error", "error_message"), + ("config_entry_data", "service_data", "error", "error_message"), [ - ({}, vol.er.Error, "required key not provided .+"), + ({}, {}, vol.er.Error, "required key not provided .+"), ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, {"incl_vat": "incorrect vat"}, vol.er.Error, "expected bool for dictionary value .+", ), ( - {"incl_vat": True, "start": "incorrect date"}, + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ( - {"incl_vat": True, "end": "incorrect date"}, + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ], + indirect=["config_entry_data"], ) async def test_service_validation( hass: HomeAssistant, service: str, + config_entry_data: dict[str, str], service_data: dict[str, str], error: type[Exception], error_message: str, @@ -84,7 +129,32 @@ async def test_service_validation( await hass.services.async_call( DOMAIN, service, - service_data, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test service calls with unloaded config entry.""" + + await mock_config_entry.async_unload(hass) + + data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True} + + with pytest.raises( + ServiceValidationError, match=f"{mock_config_entry.title} is not loaded" + ): + await hass.services.async_call( + DOMAIN, + service, + data, blocking=True, return_response=True, ) From 3419b8d0824a595d22260c27b94910c6e24835dc Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:34:19 +0100 Subject: [PATCH 904/927] Move urllib3 constraint to pyproject.toml (#106768) --- homeassistant/package_constraints.txt | 6 +----- pyproject.toml | 4 ++++ requirements.txt | 1 + script/gen_requirements_all.py | 5 ----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f63f79aac5..7ba565c4057 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -55,6 +55,7 @@ scapy==2.5.0 SQLAlchemy==2.0.23 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 @@ -65,11 +66,6 @@ zeroconf==0.131.0 # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 diff --git a/pyproject.toml b/pyproject.toml index c7f9622faa1..067275eaedb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,10 @@ dependencies = [ "requests==2.31.0", "typing-extensions>=4.9.0,<5.0", "ulid-transform==0.9.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 + # https://github.com/home-assistant/core/issues/97248 + "urllib3>=1.26.5,<2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.4", diff --git a/requirements.txt b/requirements.txt index 2cac92b4972..55cbdc31730 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,6 +30,7 @@ PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3cecff68fb0..7f652b14302 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -59,11 +59,6 @@ CONSTRAINT_BASE = """ # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 From 26cf30fc3a20cd701c9f245c083bd4e268e84a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 09:44:17 -1000 Subject: [PATCH 905/927] Update switchbot to use close_stale_connections_by_address (#106835) --- homeassistant/components/switchbot/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 445920ad276..6bad3c25142 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -98,6 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connectable means we can make connections to the device connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES address: str = entry.data[CONF_ADDRESS] + + await switchbot.close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), connectable ) @@ -106,7 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Switchbot {sensor_type} with address {address}" ) - await switchbot.close_stale_connections(ble_device) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) if cls is switchbot.SwitchbotLock: try: From 5877fe135cc5b5b5b045e9a33448c4b330bd3bc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 06:50:10 -1000 Subject: [PATCH 906/927] Close stale connections in yalexs_ble to ensure setup can proceed (#106842) --- homeassistant/components/yalexs_ble/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 11516015b6c..b5683777c24 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -10,6 +10,7 @@ from yalexs_ble import ( LockState, PushLock, YaleXSBLEError, + close_stale_connections_by_address, local_name_is_unique, ) @@ -47,6 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") + # Ensure any lingering connections are closed since the device may not be + # advertising when its connected to another client which will prevent us + # from setting the device and setup will fail. + await close_stale_connections_by_address(address) + @callback def _async_update_ble( service_info: bluetooth.BluetoothServiceInfoBleak, From 596f855eab5bda941ce299f64dc0594cd3db648c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 2 Jan 2024 08:59:45 -0500 Subject: [PATCH 907/927] Bump Zigpy to 0.60.4 (#106870) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index db5939123e4..06ebfaaa6a0 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.109", "zigpy-deconz==0.22.4", - "zigpy==0.60.3", + "zigpy==0.60.4", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 1584b61146f..e5a205c29e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2887,7 +2887,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.3 +zigpy==0.60.4 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ce1394a889..9e67c0fed70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2186,7 +2186,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.3 +zigpy==0.60.4 # homeassistant.components.zwave_js zwave-js-server-python==0.55.2 From 6f18a29241421d38a6b0fa11a911f2a9236b0d94 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 2 Jan 2024 08:51:15 -0800 Subject: [PATCH 908/927] Improve fitbit authentication error handling (#106885) --- .../components/fitbit/application_credentials.py | 2 ++ tests/components/fitbit/test_init.py | 11 +++++++++-- tests/components/fitbit/test_sensor.py | 10 +++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caa47351f45..bbd7af09183 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -69,6 +69,8 @@ class FitbitOAuth2Implementation(AuthImplementation): ) if err.status == HTTPStatus.UNAUTHORIZED: raise FitbitAuthException(f"Unauthorized error: {err}") from err + if err.status == HTTPStatus.BAD_REQUEST: + raise FitbitAuthException(f"Bad Request error: {err}") from err raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: raise FitbitApiException(f"Client connection error: {err}") from err diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 3ed3695ff3d..74312348af1 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -106,7 +106,13 @@ async def test_token_refresh_success( ) -@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.UNAUTHORIZED), + (12345, HTTPStatus.BAD_REQUEST), + ], +) @pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, @@ -114,13 +120,14 @@ async def test_token_requires_reauth( config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + server_status: HTTPStatus, closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, - status=HTTPStatus.UNAUTHORIZED, + status=server_status, closing=closing, ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 871088eae63..91aafd944b0 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -599,21 +599,25 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes"), - [(["heartrate"])], + ("scopes", "server_status"), + [ + (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), + (["heartrate"], HTTPStatus.BAD_REQUEST), + ], ) async def test_sensor_update_failed( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, + server_status: HTTPStatus, ) -> None: """Test a failed sensor update when talking to the API.""" requests_mock.register_uri( "GET", TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + status_code=server_status, ) assert await integration_setup() From b5b8bc3102843ed6e8c4674cc3963d31ad9a4bd2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 2 Jan 2024 10:50:28 -0800 Subject: [PATCH 909/927] Improve To-do service error handling (#106886) --- homeassistant/components/todo/__init__.py | 23 ++++++++++++++++----- homeassistant/components/todo/strings.json | 8 +++++++ tests/components/shopping_list/test_todo.py | 3 ++- tests/components/todo/test_init.py | 14 ++++++------- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 0f39d38eb46..afcb8e28f74 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -106,8 +106,11 @@ def _validate_supported_features( if desc.service_field not in call_data: continue if not supported_features or not supported_features & desc.required_feature: - raise ValueError( - f"Entity does not support setting field '{desc.service_field}'" + raise ServiceValidationError( + f"Entity does not support setting field '{desc.service_field}'", + translation_domain=DOMAIN, + translation_key="update_field_not_supported", + translation_placeholders={"service_field": desc.service_field}, ) @@ -481,7 +484,12 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> item = call.data["item"] found = _find_by_uid_or_summary(item, entity.todo_items) if not found: - raise ValueError(f"Unable to find To-do item '{item}'") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) _validate_supported_features(entity.supported_features, call.data) @@ -509,7 +517,12 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> for item in call.data.get("item", []): found = _find_by_uid_or_summary(item, entity.todo_items) if not found or not found.uid: - raise ValueError(f"Unable to find To-do item '{item}") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 3da921a8f47..5ef7a5fe35b 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -90,5 +90,13 @@ "completed": "Completed" } } + }, + "exceptions": { + "item_not_found": { + "message": "Unable to find To-do item: {item}" + }, + "update_field_not_supported": { + "message": "Entity does not support setting field: {service_field}" + } } } diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 7722bd8b6da..373c449497c 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.typing import WebSocketGenerator @@ -338,7 +339,7 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index e1440b292ee..5a8f6183cbb 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -347,12 +347,12 @@ async def test_add_item_service_raises( ({"item": ""}, vol.Invalid, "length of value must be at least 1"), ( {"item": "Submit forms", "description": "Submit tax forms"}, - ValueError, + ServiceValidationError, "does not support setting field 'description'", ), ( {"item": "Submit forms", "due_date": "2023-11-17"}, - ValueError, + ServiceValidationError, "does not support setting field 'due_date'", ), ( @@ -360,7 +360,7 @@ async def test_add_item_service_raises( "item": "Submit forms", "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, - ValueError, + ServiceValidationError, "does not support setting field 'due_datetime'", ), ], @@ -622,7 +622,7 @@ async def test_update_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "update_item", @@ -681,7 +681,7 @@ async def test_update_todo_item_field_unsupported( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="does not support"): + with pytest.raises(ServiceValidationError, match="does not support"): await hass.services.async_call( DOMAIN, "update_item", @@ -931,7 +931,7 @@ async def test_remove_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "remove_item", From 5100ba252f5661024641ff5756edaedd83b80b84 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Jan 2024 20:42:27 +0100 Subject: [PATCH 910/927] Update frontend to 20240102.0 (#106898) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 02a311a42ce..7579426e1e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240101.0"] + "requirements": ["home-assistant-frontend==20240102.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7ba565c4057..ae76e0afa6c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e5a205c29e5..d6f956cb734 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e67c0fed70..5d5f4763da9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 77cdc10883a0ba2c612b8aa619b80d6f9b5f89c5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 2 Jan 2024 20:59:49 +0100 Subject: [PATCH 911/927] Bump version to 2024.1.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 525c2db95ca..a56835f1fbc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 067275eaedb..e4d9a6b0c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b4" +version = "2024.1.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5eb1073b4a01bf417c3992859402a9ce4a84a51d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:37:58 +0100 Subject: [PATCH 912/927] Apply late review comments on media player (#106727) --- .../components/media_player/significant_change.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index 3e11cbdb9cd..adc96fc8b83 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -27,7 +27,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_ENTITY_PICTURE_LOCAL, ATTR_GROUP_MEMBERS, *ATTR_TO_PROPERTY, -} +} - INSIGNIFICANT_ATTRIBUTES @callback @@ -44,18 +44,10 @@ def async_check_significant_change( return True old_attrs_s = set( - { - k: v - for k, v in old_attrs.items() - if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES - }.items() + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() ) new_attrs_s = set( - { - k: v - for k, v in new_attrs.items() - if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES - }.items() + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} From 527d9fbb6b0fab85fef73e7f752125aa2d38b2d6 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 3 Jan 2024 09:15:39 +0100 Subject: [PATCH 913/927] Add try-catch for invalid auth to Tado (#106774) Co-authored-by: Martin Hjelmare --- homeassistant/components/tado/config_flow.py | 3 +++ .../components/tado/device_tracker.py | 2 ++ homeassistant/components/tado/strings.json | 6 ++++- tests/components/tado/test_config_flow.py | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 3e183b0a9b5..f9f4f80bde1 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any +import PyTado from PyTado.interface import Tado import requests.exceptions import voluptuous as vol @@ -136,6 +137,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except exceptions.HomeAssistantError: return self.async_abort(reason="import_failed") + except PyTado.exceptions.TadoWrongCredentialsException: + return self.async_abort(reason="import_failed_invalid_auth") home_id = validate_result[UNIQUE_ID] await self.async_set_unique_id(home_id) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 426c7d9ed5d..c10ab118060 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -55,6 +55,8 @@ async def async_get_scanner( translation_key = "import_aborted" if import_result.get("reason") == "import_failed": translation_key = "import_failed" + if import_result.get("reason") == "import_failed_invalid_auth": + translation_key = "import_failed_invalid_auth" async_create_issue( hass, diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 157b98e33ea..d50d1490566 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -133,9 +133,13 @@ "title": "Import aborted", "description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado." }, - "failed_to_import": { + "import_failed": { "title": "Failed to import", "description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration." + }, + "import_failed_invalid_auth": { + "title": "Failed to import, invalid credentials", + "description": "Failed to import the configuration for the Tado Device Tracker, due to invalid credentials. Please fix the YAML configuration and restart Home Assistant. Alternatively you can use the UI to configure Tado. Don't forget to delete the YAML configuration, once the import is successful." } } } diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index d83a4b22efc..ac04777dc1c 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from ipaddress import ip_address from unittest.mock import MagicMock, patch +import PyTado import pytest import requests @@ -346,6 +347,27 @@ async def test_import_step_validation_failed(hass: HomeAssistant) -> None: assert result["reason"] == "import_failed" +async def test_import_step_device_authentication_failed(hass: HomeAssistant) -> None: + """Test import step with device tracker authentication failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=PyTado.exceptions.TadoWrongCredentialsException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed_invalid_auth" + + async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: """Test import step with unique ID already configured.""" entry = MockConfigEntry( From 95ef2dd7f9077951965701b66437932187e3cdc8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 Jan 2024 15:35:48 -0600 Subject: [PATCH 914/927] Bump intents to 2024.1.2 (#106909) --- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 8 ++--- tests/components/conversation/test_init.py | 30 +++++++++---------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index cb03499d8e4..5f0c7b171ae 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae76e0afa6c..77aa0c699cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20240102.0 -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index d6f956cb734..122592994ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1041,7 +1041,7 @@ holidays==0.39 home-assistant-frontend==20240102.0 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d5f4763da9..45846bbfc2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -834,7 +834,7 @@ holidays==0.39 home-assistant-frontend==20240102.0 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f7145a9ab56..35d967f37da 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -249,7 +249,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -279,7 +279,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -309,7 +309,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -339,7 +339,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index fdbf10b0c7f..0f47f9ac3d9 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -85,7 +85,7 @@ async def test_http_processing_intent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -135,7 +135,7 @@ async def test_http_processing_intent_target_ha_agent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -186,7 +186,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -222,7 +222,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -255,7 +255,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -331,7 +331,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -364,7 +364,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -449,7 +449,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -483,7 +483,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -540,7 +540,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -624,7 +624,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -656,7 +656,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -740,7 +740,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -769,7 +769,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -855,7 +855,7 @@ async def test_http_processing_intent_conversion_not_expose_new( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, From 5986967db7c73af6f6e183322d0dc4172aa65aec Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 3 Jan 2024 06:40:42 +0100 Subject: [PATCH 915/927] Avoid triggering ping device tracker `home` after restore (#106913) --- homeassistant/components/ping/device_tracker.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index d627082a499..6b904043b30 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -136,7 +136,7 @@ async def async_setup_entry( class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" - _first_offline: datetime | None = None + _last_seen: datetime | None = None def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator @@ -171,14 +171,12 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) def is_connected(self) -> bool: """Return true if ping returns is_alive or considered home.""" if self.coordinator.data.is_alive: - self._first_offline = None - return True + self._last_seen = dt_util.utcnow() - now = dt_util.utcnow() - if self._first_offline is None: - self._first_offline = now - - return (self._first_offline + self._consider_home_interval) > now + return ( + self._last_seen is not None + and (dt_util.utcnow() - self._last_seen) < self._consider_home_interval + ) @property def entity_registry_enabled_default(self) -> bool: From 0226b3f10c28b535101184a579e9856a1c84ef7c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 2 Jan 2024 23:47:32 +0100 Subject: [PATCH 916/927] Remove group_members from significant attributes in media player (#106916) --- homeassistant/components/media_player/significant_change.py | 2 -- tests/components/media_player/test_significant_change.py | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index adc96fc8b83..43a253d9220 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -11,7 +11,6 @@ from homeassistant.helpers.significant_change import ( from . import ( ATTR_ENTITY_PICTURE_LOCAL, - ATTR_GROUP_MEMBERS, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_VOLUME_LEVEL, @@ -25,7 +24,6 @@ INSIGNIFICANT_ATTRIBUTES: set[str] = { SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_ENTITY_PICTURE_LOCAL, - ATTR_GROUP_MEMBERS, *ATTR_TO_PROPERTY, } - INSIGNIFICANT_ATTRIBUTES diff --git a/tests/components/media_player/test_significant_change.py b/tests/components/media_player/test_significant_change.py index 1b0ac6fe5aa..233f133c342 100644 --- a/tests/components/media_player/test_significant_change.py +++ b/tests/components/media_player/test_significant_change.py @@ -51,7 +51,11 @@ async def test_significant_state_change() -> None: {ATTR_ENTITY_PICTURE_LOCAL: "new_value"}, True, ), - ({ATTR_GROUP_MEMBERS: "old_value"}, {ATTR_GROUP_MEMBERS: "new_value"}, True), + ( + {ATTR_GROUP_MEMBERS: ["old1", "old2"]}, + {ATTR_GROUP_MEMBERS: ["old1", "new"]}, + False, + ), ({ATTR_INPUT_SOURCE: "old_value"}, {ATTR_INPUT_SOURCE: "new_value"}, True), ( {ATTR_MEDIA_ALBUM_ARTIST: "old_value"}, From f98bbf88b19bc2cebf7dd8f2351f0ec06a3fec0a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jan 2024 09:56:28 +0100 Subject: [PATCH 917/927] Bump version to 2024.1.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a56835f1fbc..f5e7f480427 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index e4d9a6b0c9a..b604280d8c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b5" +version = "2024.1.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3295722e70708d44bb428419da73e0f72358aa6e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 3 Jan 2024 10:30:32 +0100 Subject: [PATCH 918/927] Change Tado deprecation version to 2024.7.0 (#106938) Change version to 2024.7.0 --- homeassistant/components/tado/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index c10ab118060..9c50318639d 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -62,7 +62,7 @@ async def async_get_scanner( hass, DOMAIN, "deprecated_yaml_import_device_tracker", - breaks_in_ha_version="2024.6.0", + breaks_in_ha_version="2024.7.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key=translation_key, From 2b43f5fcdae3fb4028a914df8f58b6f856238fc8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 11:32:48 +0100 Subject: [PATCH 919/927] Update frontend to 20240103.0 (#106942) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7579426e1e1..42cd3eb1f33 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240102.0"] + "requirements": ["home-assistant-frontend==20240103.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 77aa0c699cf..9a389e61811 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 122592994ba..a05ae658de6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45846bbfc2a..7f7eaa8aaa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From 2be72fd891be0831a8df54b4a4e08a7946628ad5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jan 2024 11:35:43 +0100 Subject: [PATCH 920/927] Bump version to 2024.1.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f5e7f480427..7cdcd452385 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b604280d8c0..022046f3e51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b6" +version = "2024.1.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e74554243199ac2a5325055112d0d764b38689d5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 3 Jan 2024 12:29:05 +0100 Subject: [PATCH 921/927] Fix creating cloud hook twice for mobile_app (#106945) --- homeassistant/components/mobile_app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 94d268f9412..cb5c0ae5c3d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -118,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await create_cloud_hook() if ( - CONF_CLOUDHOOK_URL not in registration + CONF_CLOUDHOOK_URL not in entry.data and cloud.async_active_subscription(hass) and cloud.async_is_connected(hass) ): From 4595c3edaab3572299fc3b056ae6e5a13c54c5f6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 13:18:09 +0100 Subject: [PATCH 922/927] Update frontend to 20240103.1 (#106948) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 42cd3eb1f33..9a753edd059 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240103.0"] + "requirements": ["home-assistant-frontend==20240103.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a389e61811..62a10157d97 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a05ae658de6..013a53dfd47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f7eaa8aaa7..dc1c6c28bca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From 9d697c502663a89c8c994fb4d93a2bbb7e31c445 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 3 Jan 2024 13:42:55 +0100 Subject: [PATCH 923/927] Only set precision in modbus if not configured. (#106952) Only set precision if not configured. --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/base_platform.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 89a50862b6c..cc1b3c74356 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -190,7 +190,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, - vol.Optional(CONF_PRECISION, default=0): cv.positive_int, + vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( CONF_SWAP, ): vol.In( diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 1c7c8f65140..d3ec06bbdd7 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -185,10 +185,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] - self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] - if self._scale < 1 and not self._precision: - self._precision = 2 + self._precision = config.get(CONF_PRECISION, 2 if self._scale < 1 else 0) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 From 015752ff118e6e0ccccf6062e12e885098060de8 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 3 Jan 2024 14:43:17 +0100 Subject: [PATCH 924/927] Set precision to halves in flexit_bacnet (#106959) flexit_bacnet: set precision to halves for target temperature --- homeassistant/components/flexit_bacnet/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 28f4a6ae178..c15cb59a6f3 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -65,7 +65,7 @@ class FlexitClimateEntity(ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) - _attr_target_temperature_step = PRECISION_WHOLE + _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, device: FlexitBACnet) -> None: From cd8d95a04d5b76a682e7bc2fa731bc3002ebc6fe Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 15:28:22 +0100 Subject: [PATCH 925/927] Update frontend to 20240103.3 (#106963) --- homeassistant/components/frontend/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/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9a753edd059..52f3932237b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240103.1"] + "requirements": ["home-assistant-frontend==20240103.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 62a10157d97..0f069a0e0b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 013a53dfd47..46b89f491a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc1c6c28bca..ee1a9b2ac35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From 8cf47c4925071fea3fe97f5429f7609e1aa6c55c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jan 2024 15:29:59 +0100 Subject: [PATCH 926/927] Bump version to 2024.1.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7cdcd452385..cf2eb18a4d9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 022046f3e51..004b5b1a35a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b7" +version = "2024.1.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 15cecbd4a4316184b797181ae2af22c205e89d13 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jan 2024 17:13:22 +0100 Subject: [PATCH 927/927] Bump version to 2024.1.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cf2eb18a4d9..6afa0430ba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 004b5b1a35a..ec313a5bcf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0b8" +version = "2024.1.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"