From 9a9374bf453d3621892a4bf7c55965f238629c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 4 Feb 2025 16:52:40 +0000 Subject: [PATCH 01/94] Add view to download support package to Cloud component (#135856) --- homeassistant/components/cloud/http_api.py | 55 ++++++++++ .../components/system_health/__init__.py | 65 ++++++++--- .../cloud/snapshots/test_http_api.ambr | 49 +++++++++ tests/components/cloud/test_http_api.py | 102 +++++++++++++++++- 4 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 tests/components/cloud/snapshots/test_http_api.ambr diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 473f553593a..b1a845ef8b0 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.system_health import get_info as get_system_health_info from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(DownloadSupportPackageView) _CLOUD_ERRORS.update( { @@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message("ok") +class DownloadSupportPackageView(HomeAssistantView): + """Download support package view.""" + + url = "/api/cloud/support_package" + name = "api:cloud:support_package" + + def _generate_markdown( + self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + ) -> str: + def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: + if len(domain_info) == 0: + return "No information available\n" + + markdown = "" + first = True + for key, value in domain_info.items(): + markdown += f"{key} | {value}\n" + if first: + markdown += "--- | ---\n" + first = False + return markdown + "\n" + + markdown = "## System Information\n\n" + markdown += get_domain_table_markdown(hass_info) + + for domain, domain_info in domains_info.items(): + domain_info_md = get_domain_table_markdown(domain_info) + markdown += ( + f"
{domain}\n\n" + f"{domain_info_md}" + "
\n\n" + ) + + return markdown + + async def get(self, request: web.Request) -> web.Response: + """Download support package file.""" + + hass = request.app[KEY_HASS] + domain_health = await get_system_health_info(hass) + + hass_info = domain_health.pop("homeassistant", {}) + markdown = self._generate_markdown(hass_info, domain_health) + + return web.Response( + body=markdown, + content_type="text/markdown", + headers={ + "Content-Disposition": 'attachment; filename="support_package.md"' + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"}) @websocket_api.async_response diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index ce80f6303d9..7d2224fc6fc 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses from datetime import datetime import logging @@ -101,6 +101,57 @@ async def get_integration_info( return result +async def _registered_domain_data( + hass: HomeAssistant, +) -> AsyncGenerator[tuple[str, dict[str, Any]]]: + registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + strict=False, + ): + yield domain, domain_data + + +async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]: + """Get the full set of system health information.""" + domains: dict[str, dict[str, Any]] = {} + + async def _get_info_value(value: Any) -> Any: + if not asyncio.iscoroutine(value): + return value + try: + return await value + except Exception as exception: + _LOGGER.exception("Error fetching system info for %s - %s", domain, key) + return f"Exception: {exception}" + + async for domain, domain_data in _registered_domain_data(hass): + domain_info: dict[str, Any] = {} + for key, value in domain_data["info"].items(): + info_value = await _get_info_value(value) + + if isinstance(info_value, datetime): + domain_info[key] = info_value.isoformat() + elif ( + isinstance(info_value, dict) + and "type" in info_value + and info_value["type"] == "failed" + ): + domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}" + else: + domain_info[key] = info_value + + domains[domain] = domain_info + + return domains + + @callback def _format_value(val: Any) -> Any: """Format a system health value.""" @@ -115,20 +166,10 @@ async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle an info request via a subscription.""" - registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} pending_info: dict[tuple[str, str], asyncio.Task] = {} - for domain, domain_data in zip( - registrations, - await asyncio.gather( - *( - get_integration_info(hass, registration) - for registration in registrations.values() - ) - ), - strict=False, - ): + async for domain, domain_data in _registered_domain_data(hass): for key, value in domain_data["info"].items(): if asyncio.iscoroutine(value): value = asyncio.create_task(value) diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr new file mode 100644 index 00000000000..9b2f2e0eb33 --- /dev/null +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_download_support_package + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | CertificateStatus.READY + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + + ''' +# --- diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 910fa03d46c..e4a526ceadd 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,10 +1,11 @@ """Tests for the HTTP API for the cloud component.""" +from collections.abc import Callable, Coroutine from copy import deepcopy from http import HTTPStatus import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from hass_nabucasa import thingtalk @@ -15,9 +16,12 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.remote import CertificateStatus from hass_nabucasa.voice import TTS_VOICES import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import system_health from homeassistant.components.alexa import errors as alexa_errors # pylint: disable-next=hass-component-root-import @@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo +from tests.common import mock_platform from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -113,6 +119,7 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "user_pool_id": "user_pool_id", "region": "region", "relayer_server": "relayer", + "acme_server": "cert-server", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, "alexa": { @@ -1860,3 +1867,96 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.call_count == 1 assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} + + +async def test_download_support_package( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + 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() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot From 1f7d620d6b89db47de3ab81a7153b66977fe361b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 4 Feb 2025 17:54:05 +0100 Subject: [PATCH 02/94] Don't show active user initiated data entry config flows (#137334) Do not show active user initiated data entry config flows --- .../components/config/config_entries.py | 3 +- .../components/config/test_config_entries.py | 47 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 4a070a87734..52e3346002e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -302,7 +302,8 @@ def config_entries_progress( [ flw for flw in hass.config_entries.flow.async_progress() - if flw["context"]["source"] != config_entries.SOURCE_USER + if flw["context"]["source"] + not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER) ], ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ee000c5ada2..f5241f65200 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Generator from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient @@ -12,12 +13,13 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -729,27 +731,62 @@ async def test_get_progress_index( mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + class TestFlow(core_ce.ConfigFlow): VERSION = 5 - async def async_step_hassio(self, discovery_info): + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" return await self.async_step_account() - async def async_step_account(self, user_input=None): + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" return self.async_show_form(step_id="account") + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + with patch.dict(HANDLERS, {"test": TestFlow}): - form = await hass.config_entries.flow.async_init( + form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) + form_user = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_USER} + ) + form_reconfigure = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"} + ) + + for form in (form_hassio, form_user, form_reconfigure): + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["step_id"] == "account" await ws_client.send_json({"id": 5, "type": "config_entries/flow/progress"}) response = await ws_client.receive_json() assert response["success"] + + # Active flows with SOURCE_USER and SOURCE_RECONFIGURE should be filtered out assert response["result"] == [ { - "flow_id": form["flow_id"], + "flow_id": form_hassio["flow_id"], "handler": "test", "step_id": "account", "context": {"source": core_ce.SOURCE_HASSIO}, From 5dd03c037ef906658730775011009061226e1660 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 4 Feb 2025 18:11:55 +0100 Subject: [PATCH 03/94] Bump onedrive-personal-sdk to 0.0.4 (#137330) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index cd44298384a..47eb48742be 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.3"] + "requirements": ["onedrive-personal-sdk==0.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac62beec307..fc49e0ee857 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.3 +onedrive-personal-sdk==0.0.4 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb564f7e056..8a87937d950 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.3 +onedrive-personal-sdk==0.0.4 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 0c56791d9477f277494cc3886ae479b1ed1a46e8 Mon Sep 17 00:00:00 2001 From: kurens <40004079+migrzyb@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:16:59 +0100 Subject: [PATCH 04/94] Added support for One Time Charge Status to Vicare (#135984) Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Co-authored-by: kurens Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> Co-authored-by: Christopher Fenner --- .../components/vicare/binary_sensor.py | 6 +++ homeassistant/components/vicare/icons.json | 3 ++ homeassistant/components/vicare/strings.json | 3 ++ .../vicare/snapshots/test_binary_sensor.ambr | 47 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 61a5abce942..9d216404156 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -106,6 +106,12 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), ), + ViCareBinarySensorEntityDescription( + key="one_time_charge", + translation_key="one_time_charge", + device_class=BinarySensorDeviceClass.RUNNING, + value_getter=lambda api: api.getOneTimeCharge(), + ), ) diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 52148b1fa32..c54be7af0d5 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -18,6 +18,9 @@ }, "domestic_hot_water_pump": { "default": "mdi:pump" + }, + "one_time_charge": { + "default": "mdi:shower-head" } }, "button": { diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 26ca0f5a264..50eeaf038e0 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -63,6 +63,9 @@ }, "domestic_hot_water_pump": { "name": "DHW pump" + }, + "one_time_charge": { + "name": "One-time charge" } }, "button": { diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index f3e4d4e1c84..ec2451cd466 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -373,6 +373,53 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'One-time charge', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'one_time_charge', + 'unique_id': 'gateway0_deviceSerialVitodens300W-one_time_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 One-time charge', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_binary_sensors[burner] StateSnapshot({ 'attributes': ReadOnlyDict({ From f19404991c541372a388d3ba7b042a62d7063007 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 4 Feb 2025 12:20:05 -0500 Subject: [PATCH 05/94] Bump upb-lib to 0.6.0 (#137339) --- homeassistant/components/upb/__init__.py | 1 + homeassistant/components/upb/config_flow.py | 5 +++-- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/upb/test_config_flow.py | 11 ++++++++--- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index c9f3a2df105..ebfc8eaeece 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b file = config_entry.data[CONF_FILE_PATH] upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file}) + await upb.load_upstart_file() await upb.async_connect() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config_entry.entry_id] = {"upb": upb} diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 788a0336d73..af1ee7d5ab0 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -40,8 +40,9 @@ async def _validate_input(data): url = _make_url_from_data(data) upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file_path}) - - await upb.async_connect(_connected_callback) + upb.add_handler("connected", _connected_callback) + await upb.load_upstart_file() + await upb.async_connect() if not upb.config_ok: _LOGGER.error("Missing or invalid UPB file: %s", file_path) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 1e61747b3f1..e5da4c4d621 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.9"] + "requirements": ["upb-lib==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc49e0ee857..b773a71c442 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2959,7 +2959,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.25 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a87937d950..6e0686378b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2375,7 +2375,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 59a4e97d22b..3909c7e5dc4 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -13,15 +13,20 @@ from homeassistant.data_entry_flow import FlowResultType def mocked_upb(sync_complete=True, config_ok=True): """Mock UPB lib.""" - def _upb_lib_connect(callback): + def _add_handler(_, callback): callback() + def _dummy_add_handler(_, _callback): + pass + upb_mock = AsyncMock() type(upb_mock).network_id = PropertyMock(return_value="42") type(upb_mock).config_ok = PropertyMock(return_value=config_ok) type(upb_mock).disconnect = MagicMock() - if sync_complete: - upb_mock.async_connect.side_effect = _upb_lib_connect + type(upb_mock).add_handler = MagicMock() + upb_mock.add_handler.side_effect = ( + _add_handler if sync_complete else _dummy_add_handler + ) return patch( "homeassistant.components.upb.config_flow.upb_lib.UpbPim", return_value=upb_mock ) From 0895ac6a8246514d770500558a70f433011dca05 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:20:54 +0100 Subject: [PATCH 06/94] Improve backup file naming in Synology DSM backup agent (#137278) * improve backup file naming * use built-in suggested_filename --- .../components/synology_dsm/backup.py | 49 +++++++++++++++++-- tests/components/synology_dsm/test_backup.py | 46 +++++++++-------- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 5f3312717ef..83c3455bdf1 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -10,7 +10,12 @@ from aiohttp import StreamReader from synology_dsm.api.file_station import SynoFileStation from synology_dsm.exceptions import SynologyDSMAPIErrorException -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -28,6 +33,15 @@ from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Suggest filenames for the backup. + + returns a tuple of tar_filename and meta_filename + """ + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return (f"{base_name}.tar", f"{base_name}_meta.json") + + async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: @@ -95,6 +109,19 @@ class SynologyDSMBackupAgent(BackupAgent): assert self.api.file_station return self.api.file_station + async def _async_suggested_filenames( + self, + backup_id: str, + ) -> tuple[str, str]: + """Suggest filenames for the backup. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: A tuple of tar_filename and meta_filename + """ + if (backup := await self.async_get_backup(backup_id)) is None: + raise BackupAgentError("Backup not found") + return suggested_filenames(backup) + async def async_download_backup( self, backup_id: str, @@ -105,10 +132,12 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ + (filename_tar, _) = await self._async_suggested_filenames(backup_id) + try: resp = await self._file_station.download_file( path=self.path, - filename=f"{backup_id}.tar", + filename=filename_tar, ) except SynologyDSMAPIErrorException as err: raise BackupAgentError("Failed to download backup") from err @@ -131,11 +160,13 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup: Metadata about the backup that should be uploaded. """ + (filename_tar, filename_meta) = suggested_filenames(backup) + # upload backup.tar file first try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}.tar", + filename=filename_tar, source=await open_stream(), create_parents=True, ) @@ -146,7 +177,7 @@ class SynologyDSMBackupAgent(BackupAgent): try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}_meta.json", + filename=filename_meta, source=json_dumps(backup.as_dict()).encode(), ) except SynologyDSMAPIErrorException as err: @@ -161,7 +192,15 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - for filename in (f"{backup_id}.tar", f"{backup_id}_meta.json"): + try: + (filename_tar, filename_meta) = await self._async_suggested_filenames( + backup_id + ) + except BackupAgentError: + # backup meta data could not be found, so we can't delete the backup + return + + for filename in (filename_tar, filename_meta): try: await self._file_station.delete_file(path=self.path, filename=filename) except SynologyDSMAPIErrorException as err: diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index d9d3867cd63..26e09d407ff 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,6 +36,8 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" + class MockStreamReaderChunked(MockStreamReader): """Mock a stream reader with simulated chunked data.""" @@ -46,14 +48,14 @@ class MockStreamReaderChunked(MockStreamReader): async def _mock_download_file(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -61,22 +63,22 @@ async def _mock_download_file(path: str, filename: str) -> MockStreamReader: async def _mock_download_file_meta_ok_tar_missing( path: str, filename: str ) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": - raise SynologyDSMAPIErrorException("api", "404", "not found") + if filename == f"{BASE_FILENAME}.tar": + raise SynologyDSMAPIErrorException("api", "900", [{"code": 408}]) raise MockStreamReaderChunked(b"") async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader(b"im not a json") - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -84,7 +86,6 @@ async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStrea @pytest.fixture def mock_dsm_with_filestation(): """Mock a successful service with filestation support.""" - with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -115,14 +116,14 @@ def mock_dsm_with_filestation(): SynoFileFile( additional=None, is_dir=False, - name="abcd12ef_meta.json", - path="/ha_backup/my_backup_path/abcd12ef_meta.json", + name=f"{BASE_FILENAME}_meta.json", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}_meta.json", ), SynoFileFile( additional=None, is_dir=False, - name="abcd12ef.tar", - path="/ha_backup/my_backup_path/abcd12ef.tar", + name=f"{BASE_FILENAME}.tar", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}.tar", ), ] ), @@ -524,6 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" with ( patch( @@ -546,9 +548,9 @@ async def test_agents_upload( assert f"Uploading backup {backup_id}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -574,6 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" # fail to upload the tar file with ( @@ -601,7 +604,7 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" # fail to upload the meta json file @@ -632,9 +635,9 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 3 - assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[2].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" @@ -659,9 +662,9 @@ async def test_agents_delete( assert response["result"] == {"agent_errors": {}} mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{BASE_FILENAME}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -674,6 +677,9 @@ async def test_agents_delete_not_existing( client = await hass_ws_client(hass) backup_id = "ef34ab12" + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) setup_dsm_with_filestation.file.delete_file = AsyncMock( side_effect=SynologyDSMAPIErrorException( "api", @@ -742,5 +748,5 @@ async def test_agents_delete_error( assert f"Failed to delete backup: {expected_log}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" From 24ca7d95acfb4eeeb630223aa2833730f5aaa7bf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 4 Feb 2025 18:49:10 +0100 Subject: [PATCH 07/94] Bump roombapy to 1.9.0 (#137336) --- 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 edb317f9752..dbfd803f89b 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.8.1"], + "requirements": ["roombapy==1.9.0"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b773a71c442..95d7d3f7145 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2630,7 +2630,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e0686378b2..648fc898849 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 From fed36d575639644ded34b939ed2756ba1b2fd0c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 12:24:42 -0600 Subject: [PATCH 08/94] Bump uiprotect to 7.5.1 (#137343) --- 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 69c7f8b205b..a4bb6d20841 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.5.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 95d7d3f7145..cd0e4aae9fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2941,7 +2941,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 648fc898849..94e4338b3bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2366,7 +2366,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 6ff9b0541eb8f62088c1c3bf91c7ca8b1b2f462f Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 4 Feb 2025 13:27:46 -0500 Subject: [PATCH 09/94] Fix incorrect UPB service entity type (#137346) --- homeassistant/components/upb/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml index cf415705d72..985ce11c436 100644 --- a/homeassistant/components/upb/services.yaml +++ b/homeassistant/components/upb/services.yaml @@ -49,7 +49,7 @@ link_deactivate: target: entity: integration: upb - domain: light + domain: scene link_goto: target: From eb5036854f09963780cd28ea827d66e332a3e53e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 19:49:55 +0100 Subject: [PATCH 10/94] Improve error handling when supervisor backups are deleted (#137331) * Improve error handling when supervisor backups are deleted * Move exception definitions --- homeassistant/components/backup/__init__.py | 3 +- homeassistant/components/backup/agent.py | 14 +---- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/http.py | 16 +++--- homeassistant/components/backup/manager.py | 15 +++--- homeassistant/components/backup/models.py | 18 +++++++ homeassistant/components/backup/websocket.py | 6 ++- homeassistant/components/hassio/backup.py | 16 ++++-- .../backup/snapshots/test_websocket.ambr | 22 ++++++++ tests/components/backup/test_http.py | 52 ++++++++++++++++++- tests/components/backup/test_websocket.py | 37 +++++++++++++ tests/components/hassio/test_backup.py | 41 ++++++++++----- 12 files changed, 195 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 449b07e7b26..71a4f5ea41a 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -37,7 +37,7 @@ from .manager import ( RestoreBackupState, WrittenBackup, ) -from .models import AddonInfo, AgentBackup, Folder +from .models import AddonInfo, AgentBackup, BackupNotFound, Folder from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers @@ -48,6 +48,7 @@ __all__ = [ "BackupAgentError", "BackupAgentPlatformProtocol", "BackupManagerError", + "BackupNotFound", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 297ccd6f685..9530f386c7b 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -11,13 +11,7 @@ from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from .models import AgentBackup, BackupError - - -class BackupAgentError(BackupError): - """Base class for backup agent errors.""" - - error_code = "backup_agent_error" +from .models import AgentBackup, BackupAgentError class BackupAgentUnreachableError(BackupAgentError): @@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." -class BackupNotFound(BackupAgentError): - """Raised when a backup is not found.""" - - error_code = "backup_not_found" - - class BackupAgent(abc.ABC): """Backup agent interface.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index b6282186c06..c3a46a6ab1f 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,9 +11,9 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, BackupNotFound, LocalBackupAgent +from .agent import BackupAgent, LocalBackupAgent from .const import DOMAIN, LOGGER -from .models import AgentBackup +from .models import AgentBackup, BackupNotFound from .util import read_backup, suggested_filename diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 6b06db4601d..58f44d4a449 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -21,6 +21,7 @@ from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager +from .models import BackupNotFound @callback @@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password or not backup.protected: - return await self._send_backup_no_password( - request, headers, backup_id, agent_id, agent, manager + try: + if not password or not backup.protected: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager ) - return await self._send_backup_with_password( - hass, request, headers, backup_id, agent_id, password, agent, manager - ) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) async def _send_backup_no_password( self, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 42b5f522ecd..fa9ca956c22 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -50,7 +50,14 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder +from .models import ( + AgentBackup, + BackupError, + BackupManagerError, + BackupReaderWriterError, + BaseBackup, + Folder, +) from .store import BackupStore from .util import ( AsyncIteratorReader, @@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC): """Get restore events after core restart.""" -class BackupReaderWriterError(BackupError): - """Backup reader/writer error.""" - - error_code = "backup_reader_writer_error" - - class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 62118b7944f..95c5ef9809d 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -77,7 +77,25 @@ class BackupError(HomeAssistantError): error_code = "unknown" +class BackupAgentError(BackupError): + """Base class for backup agent errors.""" + + error_code = "backup_agent_error" + + class BackupManagerError(BackupError): """Backup manager error.""" error_code = "backup_manager_error" + + +class BackupReaderWriterError(BackupError): + """Backup reader/writer error.""" + + error_code = "backup_reader_writer_error" + + +class BackupNotFound(BackupAgentError, BackupManagerError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index e130b9e950f..b6d092e1913 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -15,7 +15,7 @@ from .manager import ( IncorrectPasswordError, ManagerStateEvent, ) -from .models import Folder +from .models import BackupNotFound, Folder @callback @@ -151,6 +151,8 @@ async def handle_restore( restore_folders=msg.get("restore_folders"), restore_homeassistant=msg["restore_homeassistant"], ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") else: @@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download( agent_id=msg["agent_id"], password=msg.get("password"), ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") except DecryptOnDowloadNotSupported: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 4103be14306..142c5fc01ce 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,6 +27,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupManagerError, + BackupNotFound, BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, @@ -162,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AsyncIterator[bytes]: """Download a backup file.""" - return await self._client.backups.download_backup( - backup_id, - options=supervisor_backups.DownloadBackupOptions(location=self.location), - ) + try: + return await self._client.backups.download_backup( + backup_id, + options=supervisor_backups.DownloadBackupOptions( + location=self.location + ), + ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err async def async_upload_backup( self, @@ -528,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): location=restore_location, ), ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err except SupervisorBadRequestError as err: # Supervisor currently does not transmit machine parsable error types message = err.args[0] diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index d5d15e98da6..421432fb66e 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,28 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown error', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound] + dict({ + 'error': dict({ + 'code': 'backup_not_found', + 'message': 'Backup not found', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_config_info[storage_data0] dict({ 'id': 1, diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index aac39c04d31..24fd15fc4fe 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -11,7 +11,13 @@ from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgentError, + BackupNotFound, + Folder, +) from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant @@ -141,6 +147,50 @@ async def test_downloading_remote_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "domain.test") +@pytest.mark.parametrize( + ("error", "status"), + [ + (BackupAgentError, 500), + (BackupNotFound, 404), + ], +) +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup_with_error( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + status: int, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, + ) + ], + ) + + download_mock.side_effect = error + client = await hass_client() + resp = await client.get( + "/api/backup/download/abc123?agent_id=domain.test&password=blah" + ) + assert resp.status == status + + async def _test_downloading_encrypted_backup( hass_client: ClientSessionGenerator, agent_id: str, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 613c0b69b6b..5af6d595938 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -12,6 +12,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgentError, BackupAgentPlatformProtocol, + BackupNotFound, BackupReaderWriterError, Folder, store, @@ -2967,3 +2968,39 @@ async def test_can_decrypt_on_download( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "error", + [ + BackupAgentError, + BackupNotFound, + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + error: Exception, +) -> None: + """Test can decrypt on download.""" + + await setup_backup_integration( + hass, + with_hassio=False, + backups={"test.remote": [TEST_BACKUP_ABC123]}, + remote_agents=["remote"], + ) + client = await hass_ws_client(hass) + + with patch.object(BackupAgentTest, "async_download_backup", side_effect=error): + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 866431d6b19..496dc93df32 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -584,22 +584,29 @@ async def test_agent_download( ) +@pytest.mark.parametrize( + ("backup_info", "backup_id", "agent_id"), + [ + (TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"), + (TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + (TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download_unavailable_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, + agent_id: str, + backup_id: str, + backup_info: supervisor_backups.BackupComplete, ) -> None: """Test agent download backup which does not exist.""" client = await hass_client() - backup_id = "abc123" - supervisor_client.backups.list.return_value = [TEST_BACKUP_3] - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3 - supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( - iter((b"backup data",)) - ) + supervisor_client.backups.backup_info.return_value = backup_info + supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}") assert resp.status == 404 @@ -2129,14 +2136,22 @@ async def test_reader_writer_restore_report_progress( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code", "expected_reason"), + ("supervisor_error", "expected_error_code", "expected_reason"), [ - ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( - "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + SupervisorBadRequestError("Invalid password for backup"), + "password_incorrect", + "password_incorrect", + ), + ( + SupervisorBadRequestError( + "Backup was made on supervisor version 2025.12.0, can't " + "restore on 2024.12.0. Must update supervisor first." + ), "home_assistant_error", "unknown_error", ), + (SupervisorNotFoundError(), "backup_not_found", "backup_not_found"), ], ) @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @@ -2144,15 +2159,13 @@ async def test_reader_writer_restore_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, - supervisor_error_string: str, + supervisor_error: Exception, expected_error_code: str, expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( - supervisor_error_string - ) + supervisor_client.backups.partial_restore.side_effect = supervisor_error supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS From c203307b0d6d6e2f0211aa5a2622f0f42480ae60 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:59:45 +0100 Subject: [PATCH 11/94] Update yalexs-ble to 2.5.7 (#137345) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 652f1a7b966..5e16a22af76 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==8.10.0", "yalexs-ble==2.5.6"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index f1cde31d066..5c8e98b1e6e 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 15b11719fdb..c44f0fdd1e9 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.5.6"] + "requirements": ["yalexs-ble==2.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd0e4aae9fd..d9cca6333d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94e4338b3bb..52a24f57b98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2489,7 +2489,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale From 54751ef0c7697aebca12f06a94922504456d6a42 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:59:59 +0100 Subject: [PATCH 12/94] Update led-ble to 1.1.5 (#137347) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 8608c0b2798..9a65f62202b 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index d9cca6333d3..b0933033c76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52a24f57b98..4fa8ed664b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 From 79a9f3f2c6ed2e0493f8b5c936366c7e96ae887d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 20:22:36 +0100 Subject: [PATCH 13/94] Update home-assistant-bluetooth to 1.13.1 (#137350) --- 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 e447606af84..c11c1a78299 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ ha-ffmpeg==3.2.2 habluetooth==3.21.0 hass-nabucasa==0.89.0 hassil==2.2.0 -home-assistant-bluetooth==1.13.0 +home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250203.0 home-assistant-intents==2025.1.28 httpx==0.28.1 diff --git a/pyproject.toml b/pyproject.toml index 52e0723e191..ecfed4a8e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", - "home-assistant-bluetooth==1.13.0", + "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", "Jinja2==3.1.5", "lru-dict==1.3.0", diff --git a/requirements.txt b/requirements.txt index c5b45bfb6df..e25d69a792e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ cronsim==2.6 fnv-hash-fast==1.2.2 hass-nabucasa==0.89.0 httpx==0.28.1 -home-assistant-bluetooth==1.13.0 +home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 From 94f6daa09c096afb36c81b88bc4fa94406d39876 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Feb 2025 20:26:32 +0100 Subject: [PATCH 14/94] Make Sonos action descriptions more UI- and translation-friendly (#137356) --- homeassistant/components/sonos/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index d3774e85213..07d2e2db4e0 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -87,7 +87,7 @@ "services": { "snapshot": { "name": "Snapshot", - "description": "Takes a snapshot of the media player.", + "description": "Takes a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -95,13 +95,13 @@ }, "with_group": { "name": "With group", - "description": "True or False. Also snapshot the group layout." + "description": "Whether the snapshot should include the group layout and the state of other speakers in the group." } } }, "restore": { "name": "Restore", - "description": "Restores a snapshot of the media player.", + "description": "Restores a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -109,7 +109,7 @@ }, "with_group": { "name": "[%key:component::sonos::services::snapshot::fields::with_group::name%]", - "description": "True or False. Also restore the group layout." + "description": "Whether the group layout and the state of other speakers in the group should also be restored." } } }, @@ -129,7 +129,7 @@ }, "play_queue": { "name": "Play queue", - "description": "Start playing the queue from the first item.", + "description": "Starts playing the queue from the first item.", "fields": { "queue_position": { "name": "Queue position", @@ -153,23 +153,23 @@ "fields": { "alarm_id": { "name": "Alarm ID", - "description": "ID for the alarm to be updated." + "description": "The ID of the alarm to be updated." }, "time": { "name": "Time", - "description": "Set time for the alarm." + "description": "The time for the alarm." }, "volume": { "name": "Volume", - "description": "Set alarm volume level." + "description": "The alarm volume level." }, "enabled": { "name": "Alarm enabled", - "description": "Enable or disable the alarm." + "description": "Whether or not to enable the alarm." }, "include_linked_zones": { "name": "Include linked zones", - "description": "Enable or disable including grouped rooms." + "description": "Whether the alarm also plays on grouped players." } } }, From 8da25fc2703711ea8d854a6d75049ea8e7581fc2 Mon Sep 17 00:00:00 2001 From: Matthias Lohr Date: Tue, 4 Feb 2025 20:37:59 +0100 Subject: [PATCH 15/94] Bump tololib to 1.2.2 (#137303) --- homeassistant/components/tolo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json index 14125a857f6..613fc810683 100644 --- a/homeassistant/components/tolo/manifest.json +++ b/homeassistant/components/tolo/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/tolo", "iot_class": "local_polling", "loggers": ["tololib"], - "requirements": ["tololib==1.1.0"] + "requirements": ["tololib==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0933033c76..9a842d7a3eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2896,7 +2896,7 @@ tmb==0.0.4 todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fa8ed664b5..edafc015e08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2324,7 +2324,7 @@ tilt-ble==0.2.3 todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 From ec3127f5618862977ea2163d20ec7889290b3adc Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 4 Feb 2025 20:46:01 +0100 Subject: [PATCH 16/94] Fix HomeWizard reconfigure flow throwing error for v2-API devices (#137337) Fix reconfigure flow not working for v2 --- homeassistant/components/homewizard/config_flow.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index c94f590f000..6bcc51f939e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: try: - device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect( + user_input[CONF_IP_ADDRESS], + token=reconfigure_entry.data.get(CONF_TOKEN), + ) except RecoverableError as ex: LOGGER.error(ex) @@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self._get_reconfigure_entry(), data_updates=user_input, ) - reconfigure_entry = self._get_reconfigure_entry() return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( @@ -306,7 +310,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -async def async_try_connect(ip_address: str) -> Device: +async def async_try_connect(ip_address: str, token: str | None = None) -> Device: """Try to connect. Make connection with device to test the connection @@ -317,7 +321,7 @@ async def async_try_connect(ip_address: str) -> Device: # Determine if device is v1 or v2 capable if await has_v2_api(ip_address): - energy_api = HomeWizardEnergyV2(ip_address) + energy_api = HomeWizardEnergyV2(ip_address, token=token) else: energy_api = HomeWizardEnergyV1(ip_address) From efe8a3f530398415a050cb6ce8210d55aefa8a9f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Feb 2025 20:47:29 +0100 Subject: [PATCH 17/94] Fix spelling of "ID" and sentence-casing in ovo_energy strings (#137329) --- homeassistant/components/ovo_energy/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index 3dc11e3a601..9d8e449e1d1 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -16,10 +16,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "account": "OVO account id (only add if you have multiple accounts)" + "account": "OVO account ID (only add if you have multiple accounts)" }, "description": "Set up an OVO Energy instance to access your energy usage.", - "title": "Add OVO Energy Account" + "title": "Add OVO Energy account" }, "reauth_confirm": { "data": { From 56e07efe319459a67d2f5c30866f9a28bf75c6e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 13:48:59 -0600 Subject: [PATCH 18/94] Copy area from remote parent device when creating Bluetooth devices (#137340) --- homeassistant/components/bluetooth/__init__.py | 13 ++++++------- tests/components/bluetooth/test_config_flow.py | 9 +++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c423e9e747b..c46ef22803e 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime import logging import platform -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( @@ -302,7 +302,6 @@ async def async_update_device( entry: ConfigEntry, adapter: str, details: AdapterDetails, - via_device_domain: str | None = None, via_device_id: str | None = None, ) -> None: """Update device registry entry. @@ -322,10 +321,11 @@ async def async_update_device( sw_version=details.get(ADAPTER_SW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION), ) - if via_device_id: - device_registry.async_update_device( - device_entry.id, via_device_id=via_device_id - ) + if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)): + kwargs: dict[str, Any] = {"via_device_id": via_device_id} + if not device_entry.area_id and via_device_entry.area_id: + kwargs["area_id"] = via_device_entry.area_id + device_registry.async_update_device(device_entry.id, **kwargs) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -360,7 +360,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, source_entry.title, details, - source_domain, entry.data.get(CONF_SOURCE_DEVICE_ID), ) return True diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index b8f90b3a4aa..35c1ca1eafe 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.components.bluetooth.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component from . import FakeRemoteScanner, MockBleakClient, _get_manager @@ -537,7 +537,9 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> @pytest.mark.usefixtures("enable_bluetooth") async def test_async_step_integration_discovery_remote_adapter( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, ) -> None: """Test remote adapter configuration via integration discovery.""" entry = MockConfigEntry(domain="test") @@ -547,10 +549,12 @@ async def test_async_step_integration_discovery_remote_adapter( ) scanner = FakeRemoteScanner("esp32", "esp32", connector, True) manager = _get_manager() + area_entry = area_registry.async_get_or_create("test") cancel_scanner = manager.async_register_scanner(scanner) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("test", "BB:BB:BB:BB:BB:BB")}, + suggested_area=area_entry.id, ) result = await hass.config_entries.flow.async_init( @@ -585,6 +589,7 @@ async def test_async_step_integration_discovery_remote_adapter( ) assert ble_device_entry is not None assert ble_device_entry.via_device_id == device_entry.id + assert ble_device_entry.area_id == area_entry.id await hass.config_entries.async_unload(new_entry.entry_id) await hass.config_entries.async_unload(entry.entry_id) From 7fa6f7e8755ee68ba43907944b0ad1a4b976fad6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 4 Feb 2025 20:59:28 +0100 Subject: [PATCH 19/94] Bump paho-mqtt client to version 2.1.0 (#136130) * Bump paho-mqtt client to version 2.1.0 * Remove commented code * Bump pyeconet==0.1.26 * Ensure types-paho-mqtt==1.6.0.20240321 is uninstalled if test requirements are updated * Update roombapy dependency * Remove pyeconet from exceptions list * Revert changes to install test requirements task --- homeassistant/components/econet/manifest.json | 2 +- homeassistant/components/mqtt/async_client.py | 14 ++++++------- homeassistant/components/mqtt/client.py | 20 +++++++++++-------- homeassistant/components/mqtt/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 4 ++-- requirements_test.txt | 1 - requirements_test_all.txt | 4 ++-- script/licenses.py | 1 - tests/components/mqtt/test_client.py | 2 +- 10 files changed, 27 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 6586af92d1f..bda52ee3d07 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.23"] + "requirements": ["pyeconet==0.1.26"] } diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 882e910d7e8..5f90136df44 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -51,10 +51,10 @@ class AsyncMQTTClient(MQTTClient): since the client is running in an async event loop and will never run in multiple threads. """ - self._in_callback_mutex = NullLock() - self._callback_mutex = NullLock() - self._msgtime_mutex = NullLock() - self._out_message_mutex = NullLock() - self._in_message_mutex = NullLock() - self._reconnect_delay_mutex = NullLock() - self._mid_generate_mutex = NullLock() + self._in_callback_mutex = NullLock() # type: ignore[assignment] + self._callback_mutex = NullLock() # type: ignore[assignment] + self._msgtime_mutex = NullLock() # type: ignore[assignment] + self._out_message_mutex = NullLock() # type: ignore[assignment] + self._in_message_mutex = NullLock() # type: ignore[assignment] + self._reconnect_delay_mutex = NullLock() # type: ignore[assignment] + self._mid_generate_mutex = NullLock() # type: ignore[assignment] diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 16a02e4956e..3aca566dbfc 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,7 +15,6 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any -import uuid import certifi @@ -117,7 +116,7 @@ MAX_UNSUBSCRIBES_PER_CALL = 500 MAX_PACKETS_TO_READ = 500 -type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any +type SocketType = socket.socket | ssl.SSLSocket | mqtt._WebsocketWrapper | Any # noqa: SLF001 type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None @@ -309,12 +308,13 @@ class MqttClientSetup: if (client_id := config.get(CONF_CLIENT_ID)) is None: # PAHO MQTT relies on the MQTT server to generate random client IDs. # However, that feature is not mandatory so we generate our own. - client_id = mqtt.base62(uuid.uuid4().int, padding=22) + client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( + mqtt.CallbackAPIVersion.VERSION1, client_id, protocol=proto, - transport=transport, + transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, ) self._client.setup() @@ -533,7 +533,7 @@ class MQTT: try: # Some operating systems do not allow us to set the preferred # buffer size. In that case we try some other size options. - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) # type: ignore[union-attr] except OSError as err: if new_buffer_size <= MIN_BUFFER_SIZE: _LOGGER.warning( @@ -1216,7 +1216,9 @@ class MQTT: if not future.done(): future.set_exception(asyncio.TimeoutError) - async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None: + async def _async_wait_for_mid_or_raise( + self, mid: int | None, result_code: int + ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: # pylint: disable-next=import-outside-toplevel @@ -1232,6 +1234,8 @@ class MQTT: # Create the mid event if not created, either _mqtt_handle_mid or # _async_wait_for_mid_or_raise may be executed first. + if TYPE_CHECKING: + assert mid is not None future = self._async_get_mid_future(mid) loop = self.hass.loop timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) @@ -1269,7 +1273,7 @@ def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: # pylint: disable-next=import-outside-toplevel from paho.mqtt.matcher import MQTTMatcher - matcher = MQTTMatcher() + matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True - return lambda topic: next(matcher.iter_match(topic), False) + return lambda topic: next(matcher.iter_match(topic), False) # type: ignore[no-untyped-call] diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 25e98c01aaf..1cd6ae3e47c 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["paho-mqtt==1.6.1"], + "requirements": ["paho-mqtt==2.1.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c11c1a78299..ee534fc3ec1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.12 packaging>=23.1 -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 Pillow==11.1.0 propcache==0.2.1 psutil-home-assistant==0.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9a842d7a3eb..ddefeada2df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1613,7 +1613,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_bluray panacotta==0.2 @@ -1909,7 +1909,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.26 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/requirements_test.txt b/requirements_test.txt index e281f8f92a6..16983de5706 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -41,7 +41,6 @@ types-beautifulsoup4==4.12.0.20250204 types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20250121 -types-paho-mqtt==1.6.0.20240321 types-pexpect==4.9.0.20241208 types-pillow==10.2.0.20240822 types-protobuf==5.29.1.20241207 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edafc015e08..0aade629c20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1343,7 +1343,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_viera panasonic-viera==0.4.2 @@ -1556,7 +1556,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.26 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/script/licenses.py b/script/licenses.py index 464a2fc456b..aa15a58f3bd 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -199,7 +199,6 @@ EXCEPTIONS = { "pigpio", # https://github.com/joan2937/pigpio/pull/608 "pymitv", # MIT "pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5 - "pyeconet", # https://github.com/w1ll1am23/pyeconet/pull/41 "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "pyvera", # https://github.com/maximvelichko/pyvera/pull/164 "repoze.lru", diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index ad64b39a480..2faa9310548 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -2082,7 +2082,7 @@ async def test_server_sock_buffer_size_with_websocket( client.setblocking(False) server.setblocking(False) - class FakeWebsocket(paho_mqtt.WebsocketWrapper): + class FakeWebsocket(paho_mqtt._WebsocketWrapper): def _do_handshake(self, *args, **kwargs): pass From 7914724492984298edeab9da5b1a7867cfd41161 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Feb 2025 21:02:28 +0100 Subject: [PATCH 20/94] Update frontend to 20250204.0 (#137342) --- 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 93d5488be03..b584fe5e2f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250203.0"] + "requirements": ["home-assistant-frontend==20250204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ee534fc3ec1..20ad344ddfe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.0 hass-nabucasa==0.89.0 hassil==2.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ddefeada2df..333564d0d46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0aade629c20..66fdb2f004e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 39847064595d17f7077d6b365220e02be9ce1254 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:03:09 +0100 Subject: [PATCH 21/94] Update bleak-esphome to 2.7.1 (#137354) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index e7db70acf5c..0bc3ae55236 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 1f8b505ec45..185f9ea5cf0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.0" + "bleak-esphome==2.7.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 333564d0d46..52b54febee4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.0 +bleak-esphome==2.7.1 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66fdb2f004e..3d8efcefe40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.0 +bleak-esphome==2.7.1 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 0e1ae89f1276c14a4c2bfeeb4e7047413c95658f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 4 Feb 2025 21:03:28 +0100 Subject: [PATCH 22/94] Polish tplink vacuum sensors (#137355) --- homeassistant/components/tplink/sensor.py | 19 +++++++++ .../tplink/snapshots/test_sensor.ambr | 41 +++++++++---------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 38aab26cf8b..9b21ba775a9 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -135,13 +135,17 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="clean_progress", + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( key="last_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, @@ -155,20 +159,26 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( key="total_clean_count", + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -176,6 +186,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -183,6 +194,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -190,6 +202,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -197,6 +210,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -204,6 +218,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -211,6 +226,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -218,6 +234,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -225,6 +242,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -232,6 +250,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 0d1cc9a03e4..093b92ef315 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -243,7 +243,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -279,6 +281,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'area', 'friendly_name': 'my_device Cleaning area', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -294,11 +297,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , - 'disabled_by': None, + 'disabled_by': , 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.my_device_cleaning_progress', @@ -322,20 +327,6 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_cleaning_progress-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Cleaning progress', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_device_cleaning_progress', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30', - }) -# --- # name: test_states[sensor.my_device_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -801,7 +792,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1426,7 +1419,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1462,7 +1457,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1495,7 +1492,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , From 20e08bf0edc0a55054169e26fabe41a14c78baa5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:03:54 +0100 Subject: [PATCH 23/94] Update bluetooth dependencies (#137353) --- homeassistant/components/bluetooth/manifest.json | 6 +++--- homeassistant/package_constraints.txt | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 22db886ef3f..32577b1bd7f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.8.0", - "bluetooth-adapters==0.21.1", + "bleak-retry-connector==3.8.1", + "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", "dbus-fast==2.32.0", - "habluetooth==3.21.0" + "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 20ad344ddfe..abbd505c10a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,9 +19,9 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 bleak==0.22.3 -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.23.3 cached-ipaddress==0.8.0 @@ -33,7 +33,7 @@ dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.21.0 +habluetooth==3.21.1 hass-nabucasa==0.89.0 hassil==2.2.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 52b54febee4..56fbea977ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -600,7 +600,7 @@ bizkaibus==0.1.1 bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -625,7 +625,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.89.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d8efcefe40..89029e46225 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -531,7 +531,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -549,7 +549,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.89.0 From b28ae554e2f3ca355816389a7cc2768da0873e6b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 4 Feb 2025 22:12:18 +0200 Subject: [PATCH 24/94] Bump aranet4 to 2.5.1 (#137359) --- homeassistant/components/aranet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index ac45e352bb6..3131b00cda6 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.5.0"] + "requirements": ["aranet4==2.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56fbea977ed..3f435b04b41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -494,7 +494,7 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89029e46225..18862c16397 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -464,7 +464,7 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 From b8d74a11aeee65842015614b6748375a0c41b0fd Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:12:49 -0800 Subject: [PATCH 25/94] Allow ignored screenlogic devices to be set up from the user flow (#137315) Allow ignored ScreenLogic devices to be set up from the user flow --- .../components/screenlogic/config_flow.py | 2 +- .../screenlogic/test_config_flow.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 0fdf5d96445..b4deb9b36aa 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -105,7 +105,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_gateway_select(self, user_input=None) -> ConfigFlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" - existing = self._async_current_ids() + existing = self._async_current_ids(include_ignore=False) unconfigured_gateways = { mac: gateway[SL_GATEWAY_NAME] for mac, gateway in self.discovered_gateways.items() diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 5ce777a47fa..ad8ef125dac 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -86,6 +86,53 @@ async def test_flow_discover_none(hass: HomeAssistant) -> None: assert result["step_id"] == "gateway_entry" +async def test_flow_replace_ignored(hass: HomeAssistant) -> None: + """Test we can replace ignored entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:c0:33:01:01:01", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.screenlogic.config_flow.discovery.async_discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + with patch( + "homeassistant.components.screenlogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: "00:c0:33:01:01:01"} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pentair: 01-01-01" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_discover_error(hass: HomeAssistant) -> None: """Test when discovery errors.""" From d99305513c7f0803ee4c852f54bf462b877f3608 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 4 Feb 2025 22:13:50 +0200 Subject: [PATCH 26/94] Fix Tado missing await (#137364) --- homeassistant/components/tado/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index c8eaec76255..db7b1823bd9 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -506,7 +506,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): offset, ) - self._tado.set_temperature_offset(self._device_id, offset) + await self._tado.set_temperature_offset(self._device_id, offset) await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: From b4d80696561514010cef47498489972995f603bf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Feb 2025 21:34:21 +0100 Subject: [PATCH 27/94] Bump deebot-client to 12.0.0 (#137361) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 7b05162867b..33a251c22dc 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3f435b04b41..efe640c24f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -750,7 +750,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.0.0b0 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18862c16397..f4a95f77df3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.32.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0b0 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 4ceced6405027609ded1ccb94bd737ce520d19b7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 4 Feb 2025 22:31:05 +0100 Subject: [PATCH 28/94] Fix sqlalchemy deprecation warning that `declarative_base` has moved (#137360) --- tests/components/recorder/db_schema_9.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index 784e326e1c3..6cf7085e279 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -19,8 +19,7 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id From d2b9a3b1065052633c528563a6bbdf1273e780e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 4 Feb 2025 22:36:39 +0100 Subject: [PATCH 29/94] Add sensor and weather tests to meteo_france (#137318) --- tests/components/meteo_france/conftest.py | 41 +- .../meteo_france/fixtures/raw_forecast.json | 53 ++ .../meteo_france/fixtures/raw_rain.json | 24 + .../raw_warning_current_phenomenoms.json | 13 + .../meteo_france/snapshots/test_sensor.ambr | 764 ++++++++++++++++++ .../meteo_france/snapshots/test_weather.ambr | 59 ++ tests/components/meteo_france/test_sensor.py | 32 + tests/components/meteo_france/test_weather.py | 31 + 8 files changed, 1014 insertions(+), 3 deletions(-) create mode 100644 tests/components/meteo_france/fixtures/raw_forecast.json create mode 100644 tests/components/meteo_france/fixtures/raw_rain.json create mode 100644 tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json create mode 100644 tests/components/meteo_france/snapshots/test_sensor.ambr create mode 100644 tests/components/meteo_france/snapshots/test_weather.ambr create mode 100644 tests/components/meteo_france/test_sensor.py create mode 100644 tests/components/meteo_france/test_weather.py diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 123fc00e42a..eb28ec0a838 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -2,13 +2,48 @@ from unittest.mock import patch +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import pytest +from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") + with patch("homeassistant.components.meteo_france.MeteoFranceClient") as mock_data: + mock_data = mock_data.return_value + mock_data.get_forecast.return_value = Forecast( + load_json_object_fixture("raw_forecast.json", DOMAIN) + ) + mock_data.get_rain.return_value = Rain( + load_json_object_fixture("raw_rain.json", DOMAIN) + ) + mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + ) + yield mock_data - with patch_client: - yield + +@pytest.fixture(name="config_entry") +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create and register mock config entry.""" + entry_data = { + CONF_CITY: "La Clusaz", + CONF_LATITUDE: 45.90417, + CONF_LONGITUDE: 6.42306, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + unique_id=f"{entry_data[CONF_LATITUDE], entry_data[CONF_LONGITUDE]}", + title=entry_data[CONF_CITY], + data=entry_data, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/meteo_france/fixtures/raw_forecast.json b/tests/components/meteo_france/fixtures/raw_forecast.json new file mode 100644 index 00000000000..3c0552136d2 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_forecast.json @@ -0,0 +1,53 @@ +{ + "updated_on": 1737995400, + "position": { + "country": "FR - France", + "dept": "74", + "insee": "74080", + "lat": 45.90417, + "lon": 6.42306, + "name": "La Clusaz", + "rain_product_available": 1, + "timezone": "Europe/Paris" + }, + "daily_forecast": [ + { + "T": { "max": 10.4, "min": 6.9, "sea": null }, + "dt": 1737936000, + "humidity": { "max": 90, "min": 65 }, + "precipitation": { "24h": 1.3 }, + "sun": { "rise": 1737963392, "set": 1737996163 }, + "uv": 1, + "weather12H": { "desc": "Eclaircies", "icon": "p2j" } + } + ], + "forecast": [ + { + "T": { "value": 9.1, "windchill": 5.4 }, + "clouds": 70, + "dt": 1737990000, + "humidity": 75, + "iso0": 1250, + "rain": { "1h": 0 }, + "rain snow limit": "Non pertinent", + "sea_level": 988.7, + "snow": { "1h": 0 }, + "uv": 1, + "weather": { "desc": "Eclaircies", "icon": "p2j" }, + "wind": { + "direction": 200, + "gust": 18, + "icon": "SSO", + "speed": 8 + } + } + ], + "probability_forecast": [ + { + "dt": 1737990000, + "freezing": 0, + "rain": { "3h": null, "6h": null }, + "snow": { "3h": null, "6h": null } + } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_rain.json b/tests/components/meteo_france/fixtures/raw_rain.json new file mode 100644 index 00000000000..a9f17b8a98e --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_rain.json @@ -0,0 +1,24 @@ +{ + "position": { + "lat": 48.807166, + "lon": 2.239895, + "alti": 76, + "name": "Meudon", + "country": "FR - France", + "dept": "92", + "timezone": "Europe/Paris" + }, + "updated_on": 1589995200, + "quality": 0, + "forecast": [ + { "dt": 1589996100, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996400, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996700, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589997000, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589997300, "rain": 3, "desc": "Pluie modérée" }, + { "dt": 1589997600, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589998200, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589998800, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589999400, "rain": 1, "desc": "Temps sec" } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json new file mode 100644 index 00000000000..8d84e512fb6 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json @@ -0,0 +1,13 @@ +{ + "update_time": 1591279200, + "end_validity_time": 1591365600, + "domain_id": "32", + "phenomenons_max_colors": [ + { "phenomenon_id": "6", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "4", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "5", "phenomenon_max_color_id": 3 }, + { "phenomenon_id": "2", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "1", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "3", "phenomenon_max_color_id": 2 } + ] +} diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..85fdec0fcea --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -0,0 +1,764 @@ +# serializer version: 1 +# name: test_sensor[sensor.32_weather_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.32_weather_alert', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy-alert', + 'original_name': '32 Weather alert', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '32 Weather alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.32_weather_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Canicule': 'Vert', + 'Inondation': 'Vert', + 'Neige-verglas': 'Orange', + 'Orages': 'Jaune', + 'Pluie-inondation': 'Vert', + 'Vent violent': 'Vert', + 'attribution': 'Data provided by Météo-France', + 'friendly_name': '32 Weather alert', + 'icon': 'mdi:weather-cloudy-alert', + }), + 'context': , + 'entity_id': 'sensor.32_weather_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Orange', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'La Clusaz Cloud cover', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Cloud cover', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Daily original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_daily_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Daily original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Daily precipitation', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_precipitation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'precipitation', + 'friendly_name': 'La Clusaz Daily precipitation', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.3', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:snowflake', + 'original_name': 'La Clusaz Freeze chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_freeze_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Freeze chance', + 'icon': 'mdi:snowflake', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Humidity', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'humidity', + 'friendly_name': 'La Clusaz Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Pressure', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'pressure', + 'friendly_name': 'La Clusaz Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '988.7', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-rainy', + 'original_name': 'La Clusaz Rain chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_rain_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Rain chance', + 'icon': 'mdi:weather-rainy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-snowy', + 'original_name': 'La Clusaz Snow chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_snow_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Snow chance', + 'icon': 'mdi:weather-snowy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Temperature', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'temperature', + 'friendly_name': 'La Clusaz Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_uv', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sunglasses', + 'original_name': 'La Clusaz UV', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_uv', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz UV', + 'icon': 'mdi:sunglasses', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_uv', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-windy-variant', + 'original_name': 'La Clusaz Wind gust', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind gust', + 'icon': 'mdi:weather-windy-variant', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Wind speed', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meudon_next_rain', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meudon Next rain', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '48.807166,2.239895_next_rain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + '1_hour_forecast': dict({ + '0 min': 'Temps sec', + '10 min': 'Temps sec', + '15 min': 'Pluie faible', + '20 min': 'Pluie modérée', + '25 min': 'Pluie faible', + '35 min': 'Temps sec', + '45 min': 'Temps sec', + '5 min': 'Temps sec', + '55 min': 'Temps sec', + }), + 'attribution': 'Data provided by Météo-France', + 'device_class': 'timestamp', + 'forecast_time_ref': '2020-05-20T17:35:00+00:00', + 'friendly_name': 'Meudon Next rain', + }), + 'context': , + 'entity_id': 'sensor.meudon_next_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-05-20T17:50:00+00:00', + }) +# --- diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr new file mode 100644 index 00000000000..9e7d7631479 --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_weather[weather.la_clusaz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.la_clusaz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '45.90417,6.42306', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather[weather.la_clusaz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz', + 'humidity': 75, + 'precipitation_unit': , + 'pressure': 988.7, + 'pressure_unit': , + 'supported_features': , + 'temperature': 9.1, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 200, + 'wind_speed': 28.8, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.la_clusaz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'partlycloudy', + }) +# --- diff --git a/tests/components/meteo_france/test_sensor.py b/tests/components/meteo_france/test_sensor.py new file mode 100644 index 00000000000..be77de0008b --- /dev/null +++ b/tests/components/meteo_france/test_sensor.py @@ -0,0 +1,32 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/meteo_france/test_weather.py b/tests/components/meteo_france/test_weather.py new file mode 100644 index 00000000000..cd55ac31b27 --- /dev/null +++ b/tests/components/meteo_france/test_weather.py @@ -0,0 +1,31 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.WEATHER]): + yield + + +async def test_weather( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the weather entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From 185edc371ea7e09b372432e7335a7348c8a9bcda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 15:37:33 -0600 Subject: [PATCH 30/94] Bump led-ble to 1.1.6 (#137369) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 9a65f62202b..ff620da1993 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index efe640c24f9..bc05221fed5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4a95f77df3..835ab16573c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 From 4e7cc330c60b6eacbfa893a14e831813ef6ea127 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 22:57:47 +0100 Subject: [PATCH 31/94] Update aiozoneinfo to 0.2.3 (#137370) --- 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 abbd505c10a..14d78053f43 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 -aiozoneinfo==0.2.1 +aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.0 async-upnp-client==0.43.0 diff --git a/pyproject.toml b/pyproject.toml index ecfed4a8e66..095abbc514d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiohttp-asyncmdnsresolver==0.0.3", - "aiozoneinfo==0.2.1", + "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.0", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index e25d69a792e..25393971cd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiohttp-asyncmdnsresolver==0.0.3 -aiozoneinfo==0.2.1 +aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.0 attrs==25.1.0 From 9c9a06caa040de0aca3f54d7a86c2ac453b5693e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:01:46 +0100 Subject: [PATCH 32/94] Update govee-ble to 0.42.1 (#137371) --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 5a123de7066..4d871a991a6 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -131,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.0"] + "requirements": ["govee-ble==0.42.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc05221fed5..7f93273106e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1052,7 +1052,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 835ab16573c..b989dab481a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -902,7 +902,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 From 4cb9d28289b7c7db3f452d2dd9071ab928b57325 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:02:17 +0100 Subject: [PATCH 33/94] Update bluetooth-data-tools to 1.23.4 (#137374) Co-authored-by: J. Nick Koston --- 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 32577b1bd7f..a0405eb5ef5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.8.1", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.23.3", + "bluetooth-data-tools==1.23.4", "dbus-fast==2.32.0", "habluetooth==3.21.1" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index a29a9834c9b..36d0150642e 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.23.3", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ff620da1993..309399e6958 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 90518c81483..445affbcd57 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.23.3"] + "requirements": ["bluetooth-data-tools==1.23.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 14d78053f43..090513c2302 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.8.1 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 7f93273106e..1a6c3c52a4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b989dab481a..2a816f33b5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -558,7 +558,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 From 1e99b8786840862154939e7d54aead24be6e3b66 Mon Sep 17 00:00:00 2001 From: jukrebs <76174575+MaestroOnICe@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:36:47 +0100 Subject: [PATCH 34/94] Add iometer integration (#135513) --- CODEOWNERS | 2 + homeassistant/components/iometer/__init__.py | 39 ++++ .../components/iometer/config_flow.py | 91 ++++++++++ homeassistant/components/iometer/const.py | 5 + .../components/iometer/coordinator.py | 55 ++++++ homeassistant/components/iometer/entity.py | 24 +++ homeassistant/components/iometer/icons.json | 38 ++++ .../components/iometer/manifest.json | 12 ++ .../components/iometer/quality_scale.yaml | 74 ++++++++ homeassistant/components/iometer/sensor.py | 146 +++++++++++++++ homeassistant/components/iometer/strings.json | 65 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iometer/__init__.py | 1 + tests/components/iometer/conftest.py | 57 ++++++ .../components/iometer/fixtures/reading.json | 14 ++ tests/components/iometer/fixtures/status.json | 19 ++ tests/components/iometer/test_config_flow.py | 171 ++++++++++++++++++ 21 files changed, 831 insertions(+) create mode 100644 homeassistant/components/iometer/__init__.py create mode 100644 homeassistant/components/iometer/config_flow.py create mode 100644 homeassistant/components/iometer/const.py create mode 100644 homeassistant/components/iometer/coordinator.py create mode 100644 homeassistant/components/iometer/entity.py create mode 100644 homeassistant/components/iometer/icons.json create mode 100644 homeassistant/components/iometer/manifest.json create mode 100644 homeassistant/components/iometer/quality_scale.yaml create mode 100644 homeassistant/components/iometer/sensor.py create mode 100644 homeassistant/components/iometer/strings.json create mode 100644 tests/components/iometer/__init__.py create mode 100644 tests/components/iometer/conftest.py create mode 100644 tests/components/iometer/fixtures/reading.json create mode 100644 tests/components/iometer/fixtures/status.json create mode 100644 tests/components/iometer/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 75dd38a5ac7..e510eec6dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -731,6 +731,8 @@ build.json @home-assistant/supervisor /homeassistant/components/intent/ @home-assistant/core @synesthesiam /tests/components/intent/ @home-assistant/core @synesthesiam /homeassistant/components/intesishome/ @jnimmo +/homeassistant/components/iometer/ @MaestroOnICe +/tests/components/iometer/ @MaestroOnICe /homeassistant/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py new file mode 100644 index 00000000000..5106d449fed --- /dev/null +++ b/homeassistant/components/iometer/__init__.py @@ -0,0 +1,39 @@ +"""The IOmeter integration.""" + +from __future__ import annotations + +from iometer import IOmeterClient, IOmeterConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import IOmeterConfigEntry, IOMeterCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: + """Set up IOmeter from a config entry.""" + + host = entry.data[CONF_HOST] + session = async_get_clientsession(hass) + client = IOmeterClient(host=host, session=session) + try: + await client.get_current_status() + except IOmeterConnectionError as err: + raise ConfigEntryNotReady from err + + coordinator = IOMeterCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/iometer/config_flow.py b/homeassistant/components/iometer/config_flow.py new file mode 100644 index 00000000000..ee03d09abf7 --- /dev/null +++ b/homeassistant/components/iometer/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for the IOmeter integration.""" + +from typing import Any, Final + +from iometer import IOmeterClient, IOmeterConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + +CONFIG_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) + + +class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handles the config flow for a IOmeter bridge and core.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str + self._meter_number: str + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._host = host = discovery_info.host + self._async_abort_entries_match({CONF_HOST: host}) + + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + return self.async_abort(reason="cannot_connect") + + self._meter_number = status.meter.number + + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"} + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"meter_number": self._meter_number}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._host = user_input[CONF_HOST] + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=self._host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + errors["base"] = "cannot_connect" + else: + self._meter_number = status.meter.number + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + return await self._async_create_entry() + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) + + async def _async_create_entry(self) -> ConfigFlowResult: + """Create entry.""" + return self.async_create_entry( + title=f"IOmeter {self._meter_number}", + data={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/iometer/const.py b/homeassistant/components/iometer/const.py new file mode 100644 index 00000000000..797aefcd7f0 --- /dev/null +++ b/homeassistant/components/iometer/const.py @@ -0,0 +1,5 @@ +"""Constants for the IOmeter integration.""" + +from typing import Final + +DOMAIN: Final = "iometer" diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py new file mode 100644 index 00000000000..3321b032e4b --- /dev/null +++ b/homeassistant/components/iometer/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for IOmeter.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator] + + +@dataclass +class IOmeterData: + """Class for data update.""" + + reading: Reading + status: Status + + +class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): + """Class to manage fetching IOmeter data.""" + + config_entry: IOmeterConfigEntry + client: IOmeterClient + + def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None: + """Initialize coordinator.""" + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.client = client + self.identifier = self.config_entry.entry_id + + async def _async_update_data(self) -> IOmeterData: + """Update data async.""" + try: + reading = await self.client.get_current_reading() + status = await self.client.get_current_status() + except IOmeterConnectionError as error: + raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py new file mode 100644 index 00000000000..86494857e18 --- /dev/null +++ b/homeassistant/components/iometer/entity.py @@ -0,0 +1,24 @@ +"""Base class for IOmeter entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IOMeterCoordinator + + +class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): + """Defines a base IOmeter entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: IOMeterCoordinator) -> None: + """Initialize IOmeter entity.""" + super().__init__(coordinator) + status = coordinator.data.status + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, status.device.id)}, + manufacturer="IOmeter GmbH", + model="IOmeter", + sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + ) diff --git a/homeassistant/components/iometer/icons.json b/homeassistant/components/iometer/icons.json new file mode 100644 index 00000000000..8c71684f859 --- /dev/null +++ b/homeassistant/components/iometer/icons.json @@ -0,0 +1,38 @@ +{ + "entity": { + "sensor": { + "attachment_status": { + "default": "mdi:eye", + "state": { + "attached": "mdi:check-bold", + "detached": "mdi:close", + "unknown": "mdi:help" + } + }, + "connection_status": { + "default": "mdi:eye", + "state": { + "connected": "mdi:check-bold", + "disconnected": "mdi:close", + "unknown": "mdi:help" + } + }, + "pin_status": { + "default": "mdi:eye", + "state": { + "entered": "mdi:lock-open", + "pending": "mdi:lock-clock", + "missing": "mdi:lock", + "unknown": "mdi:help" + } + }, + "power_status": { + "default": "mdi:eye", + "state": { + "battery": "mdi:battery", + "wired": "mdi:power-plug" + } + } + } + } +} diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json new file mode 100644 index 00000000000..061a2318e04 --- /dev/null +++ b/homeassistant/components/iometer/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "iometer", + "name": "IOmeter", + "codeowners": ["@MaestroOnICe"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iometer", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["iometer==0.1.0"], + "zeroconf": ["_iometer._tcp.local."] +} diff --git a/homeassistant/components/iometer/quality_scale.yaml b/homeassistant/components/iometer/quality_scale.yaml new file mode 100644 index 00000000000..71496d8043c --- /dev/null +++ b/homeassistant/components/iometer/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not register any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has not option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: This integration polls data using a coordinator, there is no need for parallel updates. + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py new file mode 100644 index 00000000000..7d4c1155e8b --- /dev/null +++ b/homeassistant/components/iometer/sensor.py @@ -0,0 +1,146 @@ +"""IOmeter sensors.""" + +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, + SIGNAL_STRENGTH_DECIBELS, + STATE_UNKNOWN, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterEntityDescription(SensorEntityDescription): + """Describes IOmeter sensor entity.""" + + value_fn: Callable[[IOmeterData], str | int | float] + + +SENSOR_TYPES: list[IOmeterEntityDescription] = [ + IOmeterEntityDescription( + key="meter_number", + translation_key="meter_number", + icon="mdi:meter-electric", + value_fn=lambda data: data.status.meter.number, + ), + IOmeterEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.bridge.rssi, + ), + IOmeterEntityDescription( + key="core_bridge_rssi", + translation_key="core_bridge_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.core.rssi, + ), + IOmeterEntityDescription( + key="power_status", + translation_key="power_status", + device_class=SensorDeviceClass.ENUM, + options=["battery", "wired", "unknown"], + value_fn=lambda data: data.status.device.core.power_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="battery_level", + translation_key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.status.device.core.battery_level, + ), + IOmeterEntityDescription( + key="pin_status", + translation_key="pin_status", + device_class=SensorDeviceClass.ENUM, + options=["entered", "pending", "missing", "unknown"], + value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="total_consumption", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_consumption(), + ), + IOmeterEntityDescription( + key="total_production", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_production(), + ), + IOmeterEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.reading.get_current_power(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterSensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterSensor(IOmeterEntity, SensorEntity): + """Defines a IOmeter sensor.""" + + entity_description: IOmeterEntityDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json new file mode 100644 index 00000000000..31deb16aa9c --- /dev/null +++ b/homeassistant/components/iometer/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "step": { + "user": { + "description": "Setup your IOmeter device for local data", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the IOmeter device to connect to." + } + }, + "zeroconf_confirm": { + "title": "Discovered IOmeter", + "description": "Do you want to set up IOmeter on the meter with meter number: {meter_number}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "Unexpected error" + } + }, + "entity": { + "sensor": { + "battery_level": { + "name": "Battery level" + }, + "meter_number": { + "name": "Meter number" + }, + "pin_status": { + "name": "PIN status", + "state": { + "entered": "Entered", + "pending": "Pending", + "missing": "Missing", + "unknown": "Unknown" + } + }, + "power_status": { + "name": "Power supply", + "state": { + "battery": "Battery", + "wired": "Wired" + } + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "core_bridge_rssi": { + "name": "Signal strength Core/Bridge" + }, + "wifi_rssi": { + "name": "Signal strength Wi-Fi" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3c8a1d40dc2..d0a8e821f8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,7 @@ FLOWS = { "inkbird", "insteon", "intellifire", + "iometer", "ios", "iotawatt", "iotty", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 57b58e60ed6..026eab30f8f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2929,6 +2929,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "iometer": { + "name": "IOmeter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ios": { "name": "Home Assistant iOS", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index be15d88aec2..8244f19660f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -614,6 +614,11 @@ ZEROCONF = { "domain": "homewizard", }, ], + "_iometer._tcp.local.": [ + { + "domain": "iometer", + }, + ], "_ipp._tcp.local.": [ { "domain": "ipp", diff --git a/requirements_all.txt b/requirements_all.txt index 1a6c3c52a4e..2940c9e7f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1228,6 +1228,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a816f33b5f..8bce9a6839d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1042,6 +1042,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py new file mode 100644 index 00000000000..5c08438925e --- /dev/null +++ b/tests/components/iometer/__init__.py @@ -0,0 +1 @@ +"""Tests for the IOmeter integration.""" diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py new file mode 100644 index 00000000000..ee45021952e --- /dev/null +++ b/tests/components/iometer/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the IOmeter tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from iometer import Reading, Status +import pytest + +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.iometer.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_iometer_client() -> Generator[AsyncMock]: + """Mock a new IOmeter client.""" + with ( + patch( + "homeassistant.components.iometer.IOmeterClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.iometer.config_flow.IOmeterClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "10.0.0.2" + client.get_current_reading.return_value = Reading.from_json( + load_fixture("reading.json", DOMAIN) + ) + client.get_current_status.return_value = Status.from_json( + load_fixture("status.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a IOmeter config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="IOmeter-1ISK0000000000", + data={CONF_HOST: "10.0.0.2"}, + unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + ) diff --git a/tests/components/iometer/fixtures/reading.json b/tests/components/iometer/fixtures/reading.json new file mode 100644 index 00000000000..82190c88883 --- /dev/null +++ b/tests/components/iometer/fixtures/reading.json @@ -0,0 +1,14 @@ +{ + "__typename": "iometer.reading.v1", + "meter": { + "number": "1ISK0000000000", + "reading": { + "time": "2024-11-11T11:11:11Z", + "registers": [ + { "obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh" }, + { "obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh" }, + { "obis": "01-00:10.07.00*ff", "value": 100, "unit": "W" } + ] + } + } +} diff --git a/tests/components/iometer/fixtures/status.json b/tests/components/iometer/fixtures/status.json new file mode 100644 index 00000000000..4d3001d8454 --- /dev/null +++ b/tests/components/iometer/fixtures/status.json @@ -0,0 +1,19 @@ +{ + "__typename": "iometer.status.v1", + "meter": { + "number": "1ISK0000000000" + }, + "device": { + "bridge": { "rssi": -30, "version": "build-65" }, + "id": "658c2b34-2017-45f2-a12b-731235f8bb97", + "core": { + "connectionStatus": "connected", + "rssi": -30, + "version": "build-58", + "powerStatus": "battery", + "batteryLevel": 100, + "attachmentStatus": "attached", + "pinStatus": "entered" + } + } +} diff --git a/tests/components/iometer/test_config_flow.py b/tests/components/iometer/test_config_flow.py new file mode 100644 index 00000000000..49fce459282 --- /dev/null +++ b/tests/components/iometer/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the IOmeter config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from iometer import IOmeterConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +IP_ADDRESS = "10.0.0.2" +IOMETER_DEVICE_ID = "658c2b34-2017-45f2-a12b-731235f8bb97" + +ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], + hostname="IOmeter-EC63E8.local.", + name="IOmeter-EC63E8", + port=80, + type="_iometer._tcp.", + properties={}, +) + + +async def test_user_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts with duplicate.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow error.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_iometer_client.get_current_status.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_abort_duplicate( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 6b32587d10f023e9e2cf5065d1b0121528f1e5d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 17:11:05 -0600 Subject: [PATCH 35/94] Allow ignored Bluetooth adapters to be set up from the user flow (#137373) --- .../components/bluetooth/config_flow.py | 6 +---- .../components/bluetooth/strings.json | 2 +- .../components/bluetooth/test_config_flow.py | 22 ++++++++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5d03a9c9d0f..e76277306f5 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -140,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): title=adapter_title(adapter, details), data={} ) - configured_addresses = self._async_current_ids() + configured_addresses = self._async_current_ids(include_ignore=False) bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters @@ -155,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: - ignored_adapters = len( - self._async_current_entries(include_ignore=True) - ) - len(self._async_current_entries(include_ignore=False)) return self.async_abort( reason="no_adapters", - description_placeholders={"ignored_adapters": str(ignored_adapters)}, ) if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 5f9a380d631..866b76c0985 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -23,7 +23,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." + "no_adapters": "No unconfigured Bluetooth adapters found." } }, "options": { diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 35c1ca1eafe..f0136396c22 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -517,8 +517,10 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non @pytest.mark.usefixtures("one_adapter") -async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: - """Test we give a hint that the adapter is ignored.""" +async def test_async_step_user_linux_adapter_replace_ignored( + hass: HomeAssistant, +) -> None: + """Test we can replace an ignored adapter from user flow.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="00:00:00:00:00:01", @@ -530,9 +532,19 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USER}, data={}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_adapters" - assert result["description_placeholders"] == {"ignored_adapters": "1"} + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("enable_bluetooth") From c965dfb619f99fe3e2a8832b4f7cffb1c71ec56e Mon Sep 17 00:00:00 2001 From: Stephan Jauernick Date: Wed, 5 Feb 2025 01:53:20 +0100 Subject: [PATCH 36/94] Bump thermopro-ble to 0.11.0 (#137381) Co-authored-by: J. Nick Koston --- 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 2c066d785ca..6027e4bc99c 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.1"] + "requirements": ["thermopro-ble==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2940c9e7f4c..0b0f3515afa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2878,7 +2878,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.1 +thermopro-ble==0.11.0 # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bce9a6839d..a7de8d0df10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2315,7 +2315,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.1 +thermopro-ble==0.11.0 # homeassistant.components.lg_thinq thinqconnect==1.0.2 From 8e439cbf47821b6e479ad95dfd95e731c1485e4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 19:55:45 -0600 Subject: [PATCH 37/94] Bump nexia to 2.0.9 (#137383) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 0013cd63de1..6a439f869c9 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.8"] + "requirements": ["nexia==2.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b0f3515afa..73d535264dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1477,7 +1477,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7de8d0df10..38102e992ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From bbe6804572a9966f7a91f4ae3256c2196c047c80 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:16:43 +0100 Subject: [PATCH 38/94] Update dhcp dependencies (#137384) --- homeassistant/components/dhcp/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 0eb7e4a64fc..45af4f1b5dd 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -14,8 +14,8 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.0.3", - "aiodiscover==2.1.0", + "aiodhcpwatcher==1.1.0", + "aiodiscover==2.2.2", "cached-ipaddress==0.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 090513c2302..b4c64e02df0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.0.3 -aiodiscover==2.1.0 +aiodhcpwatcher==1.1.0 +aiodiscover==2.2.2 aiodns==3.2.0 aiohasupervisor==0.2.2b6 aiohttp-asyncmdnsresolver==0.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 73d535264dd..49e29ec9f7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,10 +216,10 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.3 +aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.2.2 # homeassistant.components.dnsip aiodns==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38102e992ec..fff0fe808d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,10 +204,10 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.3 +aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.2.2 # homeassistant.components.dnsip aiodns==3.2.0 From 4d567c621642125795689d1cad8bf2d24a33a52f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:17:04 +0100 Subject: [PATCH 39/94] Update bthome-ble to 3.12.4 (#137385) --- 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 c8577113804..4130606ff5c 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.12.3"] + "requirements": ["bthome-ble==3.12.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49e29ec9f7d..cf5571e3640 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.3 +bthome-ble==3.12.4 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fff0fe808d1..4182d4e1af4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.3 +bthome-ble==3.12.4 # homeassistant.components.buienradar buienradar==1.0.6 From 4111ca1a462584230e9d34d20c9f24e97d57c0e0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:17:26 +0100 Subject: [PATCH 40/94] Update aiohttp-fast-zlib to 0.2.2 (#137387) --- 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 b4c64e02df0..3bc8ad7e449 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.2.2 aiodns==3.2.0 aiohasupervisor==0.2.2b6 aiohttp-asyncmdnsresolver==0.0.3 -aiohttp-fast-zlib==0.2.0 +aiohttp-fast-zlib==0.2.2 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 diff --git a/pyproject.toml b/pyproject.toml index 095abbc514d..873e3609551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "aiohasupervisor==0.2.2b6", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.0", + "aiohttp-fast-zlib==0.2.2", "aiohttp-asyncmdnsresolver==0.0.3", "aiozoneinfo==0.2.3", "astral==2.2", diff --git a/requirements.txt b/requirements.txt index 25393971cd3..f7c94d75c05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==3.2.0 aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.0 +aiohttp-fast-zlib==0.2.2 aiohttp-asyncmdnsresolver==0.0.3 aiozoneinfo==0.2.3 astral==2.2 From 1bbd5d7954454accd3e804006eabc2719aec4d94 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:17:47 +0100 Subject: [PATCH 41/94] Update async-interrupt to 1.2.1 (#137388) --- 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 3bc8ad7e449..bf9b7262194 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 +async-interrupt==1.2.1 async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 diff --git a/pyproject.toml b/pyproject.toml index 873e3609551..f1baf85cdf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "aiohttp-asyncmdnsresolver==0.0.3", "aiozoneinfo==0.2.3", "astral==2.2", - "async-interrupt==1.2.0", + "async-interrupt==1.2.1", "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index f7c94d75c05..1a80837e2cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ aiohttp-fast-zlib==0.2.2 aiohttp-asyncmdnsresolver==0.0.3 aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 +async-interrupt==1.2.1 attrs==25.1.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1;python_version>='3.13' From d89412ca7500f5321c5be8ce8dd52b3f86035e89 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:18:10 +0100 Subject: [PATCH 42/94] Update aionut to 4.3.4 (#137389) --- homeassistant/components/nut/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 9e968b5a349..fb6c8561b25 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["aionut"], - "requirements": ["aionut==4.3.3"], + "requirements": ["aionut==4.3.4"], "zeroconf": ["_nut._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index cf5571e3640..eed001f0fff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -315,7 +315,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue aiooncue==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4182d4e1af4..cca7e603ab7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -297,7 +297,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue aiooncue==0.3.7 From ec6896c8191ebac8ee7be04e319c62cef98a7d7e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:18:50 +0100 Subject: [PATCH 43/94] Update aiosteamist to 1.0.1 (#137391) --- homeassistant/components/steamist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index b15d7f87312..f79c2aaa99c 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.0", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index eed001f0fff..f8c2b92ac23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio aiostreammagic==2.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cca7e603ab7..37e31b0e834 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -365,7 +365,7 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio aiostreammagic==2.10.0 From 369f897f41b6f0914a20653c486f315bbfda6f27 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:32:11 +0100 Subject: [PATCH 44/94] Update aiooncue to 0.3.9 (#137392) --- homeassistant/components/oncue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index b4c425a1645..33d56f23669 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/oncue", "iot_class": "cloud_polling", "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.7"] + "requirements": ["aiooncue==0.3.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index f8c2b92ac23..b643ebc3a87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -318,7 +318,7 @@ aionotion==2024.03.0 aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37e31b0e834..522ceac309d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -300,7 +300,7 @@ aionotion==2024.03.0 aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 From 280f61dd77edda72b16fab757178abe15f9cc8da Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 13:34:18 +1100 Subject: [PATCH 45/94] Add update entity for second Zigbee radio (#136918) * Add get_radio helper function This is defined here primarily for use in simplifying otherwise repetitive logic in the lambdas for entity descriptions. * Get firmware manifests for second radio * Create optional update entity for radio 2 * Add info fixture for SLZB-MR1 * Test for firmware updates of second radio * Remove use of entity description creating entities * Add idx to lambda functions * Add latest_version lambda to ED * Use Single zb_update description * test radio2 update * device type heading for release notes * fix failing no internet test * update release note tests * assert radios * fix return type installed_version * refactor latest_version code * update listener * Dont create update entities for legacy firmware that can't upgrade * Address review comments for update listener --- homeassistant/components/smlight/__init__.py | 8 +- .../components/smlight/coordinator.py | 29 +++-- homeassistant/components/smlight/update.py | 105 +++++++++++------ tests/components/smlight/conftest.py | 5 +- .../smlight/fixtures/esp_firmware.json | 4 +- .../smlight/fixtures/info-2.3.6.json | 19 +++ .../components/smlight/fixtures/info-MR1.json | 41 +++++++ .../smlight/fixtures/zb_firmware.json | 15 +-- .../smlight/fixtures/zb_firmware_router.json | 13 +++ .../smlight/snapshots/test_update.ambr | 4 +- tests/components/smlight/test_init.py | 3 +- tests/components/smlight/test_update.py | 110 +++++++++++++++--- 12 files changed, 273 insertions(+), 83 deletions(-) create mode 100644 tests/components/smlight/fixtures/info-2.3.6.json create mode 100644 tests/components/smlight/fixtures/info-MR1.json create mode 100644 tests/components/smlight/fixtures/zb_firmware_router.json diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index cbfb8162d63..11c6ffb73fb 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pysmlight import Api2 +from pysmlight import Api2, Info, Radio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -61,3 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_radio(info: Info, idx: int) -> Radio: + """Get the radio object from the info.""" + assert info.radios is not None + return info.radios[idx] diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 6be36439e9f..341c627afe5 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError -from pysmlight.web import Firmware +from pysmlight.models import FirmwareList from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -38,8 +38,8 @@ class SmFwData: """SMLIGHT firmware data stored in the FirmwareUpdateCoordinator.""" info: Info - esp_firmware: list[Firmware] | None - zb_firmware: list[Firmware] | None + esp_firmware: FirmwareList + zb_firmware: list[FirmwareList] class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -144,15 +144,30 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + assert info.radios is not None esp_firmware = None - zb_firmware = None + zb_firmware: list[FirmwareList] = [] try: esp_firmware = await self.client.get_firmware_version(info.fw_channel) - zb_firmware = await self.client.get_firmware_version( - info.fw_channel, device=info.model, mode="zigbee" + zb_firmware.extend( + [ + await self.client.get_firmware_version( + info.fw_channel, + device=info.model, + mode="zigbee", + zb_type=r.zb_type, + idx=idx, + ) + for idx, r in enumerate(info.radios) + ] ) + except SmlightConnectionError as err: self.async_set_update_error(err) - return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) + return SmFwData( + info=info, + esp_firmware=esp_firmware, + zb_firmware=zb_firmware, + ) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 147b1d766ef..50a123345c6 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Final +from typing import Any from pysmlight.const import Events as SmEvents from pysmlight.models import Firmware, Info @@ -22,34 +22,43 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmConfigEntry +from . import SmConfigEntry, get_radio from .const import LOGGER from .coordinator import SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: + """Get the latest Zigbee firmware version.""" + + if idx < len(data.zb_firmware): + firmware_list = data.zb_firmware[idx] + if firmware_list: + return firmware_list[0] + return None + + @dataclass(frozen=True, kw_only=True) class SmUpdateEntityDescription(UpdateEntityDescription): """Describes SMLIGHT SLZB-06 update entity.""" - installed_version: Callable[[Info], str | None] - fw_list: Callable[[SmFwData], list[Firmware] | None] + installed_version: Callable[[Info, int], str | None] + latest_version: Callable[[SmFwData, int], Firmware | None] -UPDATE_ENTITIES: Final = [ - SmUpdateEntityDescription( - key="core_update", - translation_key="core_update", - installed_version=lambda x: x.sw_version, - fw_list=lambda x: x.esp_firmware, - ), - SmUpdateEntityDescription( - key="zigbee_update", - translation_key="zigbee_update", - installed_version=lambda x: x.zb_version, - fw_list=lambda x: x.zb_firmware, - ), -] +CORE_UPDATE_ENTITY = SmUpdateEntityDescription( + key="core_update", + translation_key="core_update", + installed_version=lambda x, idx: x.sw_version, + latest_version=lambda x, idx: x.esp_firmware[0] if x.esp_firmware else None, +) + +ZB_UPDATE_ENTITY = SmUpdateEntityDescription( + key="zigbee_update", + translation_key="zigbee_update", + installed_version=lambda x, idx: get_radio(x, idx).zb_version, + latest_version=zigbee_latest_version, +) async def async_setup_entry( @@ -58,10 +67,21 @@ async def async_setup_entry( """Set up the SMLIGHT update entities.""" coordinator = entry.runtime_data.firmware - async_add_entities( - SmUpdateEntity(coordinator, description) for description in UPDATE_ENTITIES + # updates not available for legacy API, user will get repair to update externally + if coordinator.legacy_api == 2: + return + + entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] + radios = coordinator.data.info.radios + assert radios is not None + + entities.extend( + SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) + for idx, _ in enumerate(radios) ) + async_add_entities(entities) + class SmUpdateEntity(SmEntity, UpdateEntity): """Representation for SLZB-06 update entities.""" @@ -80,42 +100,46 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self, coordinator: SmFirmwareUpdateCoordinator, description: SmUpdateEntityDescription, + idx: int = 0, ) -> None: """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + device = description.key + (f"_{idx}" if idx else "") + self._attr_unique_id = f"{coordinator.unique_id}-{device}" self._finished_event = asyncio.Event() self._firmware: Firmware | None = None self._unload: list[Callable] = [] + self.idx = idx + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update callbacks.""" + self._firmware = self.entity_description.latest_version( + self.coordinator.data, self.idx + ) + if self._firmware: + self.async_write_ha_state() @property def installed_version(self) -> str | None: """Version installed..""" data = self.coordinator.data - version = self.entity_description.installed_version(data.info) - return version if version != "-1" else None + return self.entity_description.installed_version(data.info, self.idx) @property def latest_version(self) -> str | None: """Latest version available for install.""" - data = self.coordinator.data - if self.coordinator.legacy_api == 2: - return None - fw = self.entity_description.fw_list(data) - - if fw and self.entity_description.key == "zigbee_update": - fw = [f for f in fw if f.type == data.info.zb_type] - - if fw: - self._firmware = fw[0] - return self._firmware.ver - - return None + return self._firmware.ver if self._firmware else None def register_callbacks(self) -> None: """Register callbacks for SSE update events.""" @@ -143,9 +167,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def release_notes(self) -> str | None: """Return release notes for firmware.""" + if "zigbee" in self.entity_description.key: + notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n" + else: + notes = "### Core Firmware\n\n" if self._firmware and self._firmware.notes: - return self._firmware.notes + notes += self._firmware.notes + return notes return None @@ -192,7 +221,7 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self._attr_update_percentage = None self.register_callbacks() - await self.coordinator.client.fw_update(self._firmware) + await self.coordinator.client.fw_update(self._firmware, self.idx) # block until update finished event received await self._finished_event.wait() diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 80e89e4eb16..0b1bf24c19a 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -92,7 +92,10 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return the firmware version.""" fw_list = [] if kwargs.get("mode") == "zigbee": - fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + if kwargs.get("zb_type") == 0: + fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + else: + fw_list = load_json_array_fixture("zb_firmware_router.json", DOMAIN) else: fw_list = load_json_array_fixture("esp_firmware.json", DOMAIN) diff --git a/tests/components/smlight/fixtures/esp_firmware.json b/tests/components/smlight/fixtures/esp_firmware.json index 6ea0e1a8b44..f0ee9eb989a 100644 --- a/tests/components/smlight/fixtures/esp_firmware.json +++ b/tests/components/smlight/fixtures/esp_firmware.json @@ -2,10 +2,10 @@ { "mode": "ESP", "type": null, - "notes": "CHANGELOG (Current 2.5.2 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", + "notes": "CHANGELOG (Current 2.7.5 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", "rev": "20240830", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.5.2-ota.bin", - "ver": "v2.5.2", + "ver": "v2.7.5", "dev": false, "prod": true, "baud": null diff --git a/tests/components/smlight/fixtures/info-2.3.6.json b/tests/components/smlight/fixtures/info-2.3.6.json new file mode 100644 index 00000000000..e3defb4410e --- /dev/null +++ b/tests/components/smlight/fixtures/info-2.3.6.json @@ -0,0 +1,19 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-06p7", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-06p7", + "ram_total": 296, + "sw_version": "v2.3.6", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0 +} diff --git a/tests/components/smlight/fixtures/info-MR1.json b/tests/components/smlight/fixtures/info-MR1.json new file mode 100644 index 00000000000..df1c0b0f789 --- /dev/null +++ b/tests/components/smlight/fixtures/info-MR1.json @@ -0,0 +1,41 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-MR1", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-MR1", + "ram_total": 296, + "sw_version": "v2.7.3", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "EFR32MG21", + "zb_version": 20241127, + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + }, + { + "chip_index": 1, + "zb_hw": "CC2652P7", + "zb_version": 20240314, + "zb_type": 1, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] +} diff --git a/tests/components/smlight/fixtures/zb_firmware.json b/tests/components/smlight/fixtures/zb_firmware.json index ca9d10f87ac..b35bb20d64e 100644 --- a/tests/components/smlight/fixtures/zb_firmware.json +++ b/tests/components/smlight/fixtures/zb_firmware.json @@ -3,24 +3,13 @@ "mode": "ZB", "type": 0, "notes": "SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]:
- +20dB TRANSMIT POWER SUPPORT;
- SDK 7.41 based (latest);
", - "rev": "20240716", + "rev": "20250201", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", - "ver": "20240716", + "ver": "20250201", "dev": false, "prod": true, "baud": 115200 }, - { - "mode": "ZB", - "type": 1, - "notes": "SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use", - "rev": "20240716", - "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/zr-ZR_SLZB-06P10-20240716.bin", - "ver": "20240716", - "dev": false, - "prod": true, - "baud": 0 - }, { "mode": "ZB", "type": 0, diff --git a/tests/components/smlight/fixtures/zb_firmware_router.json b/tests/components/smlight/fixtures/zb_firmware_router.json new file mode 100644 index 00000000000..320fef89347 --- /dev/null +++ b/tests/components/smlight/fixtures/zb_firmware_router.json @@ -0,0 +1,13 @@ +[ + { + "mode": "ZB", + "type": 1, + "notes": "SMLIGHT latest ROUTER release for CC2652P7 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use - by downloading and installing this firmware, you agree to the aforementioned terms.", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 115200 + } +] diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index ed0085dcdc8..8c6757d5b91 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -42,7 +42,7 @@ 'friendly_name': 'Mock Title Core firmware', 'in_progress': False, 'installed_version': 'v2.3.6', - 'latest_version': 'v2.5.2', + 'latest_version': 'v2.7.5', 'release_summary': None, 'release_url': None, 'skipped_version': None, @@ -101,7 +101,7 @@ 'friendly_name': 'Mock Title Zigbee firmware', 'in_progress': False, 'installed_version': '20240314', - 'latest_version': '20240716', + 'latest_version': '20250201', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index d0c5e494ae8..0acbab9f3a4 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -85,6 +85,7 @@ async def test_async_setup_no_internet( freezer: FrozenDateTimeFactory, ) -> None: """Test we still load integration when no internet is available.""" + side_effect = mock_smlight_client.get_firmware_version.side_effect mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError await setup_integration(hass, mock_config_entry_host) @@ -101,7 +102,7 @@ async def test_async_setup_no_internet( assert entity is not None assert entity.state == STATE_UNKNOWN - mock_smlight_client.get_firmware_version.side_effect = None + mock_smlight_client.get_firmware_version.side_effect = side_effect freezer.tick(SCAN_FIRMWARE_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 4fca7369116..632f1b5f26b 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -4,13 +4,13 @@ from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pysmlight import Firmware, Info +from pysmlight import Firmware, Info, Radio from pysmlight.const import Events as SmEvents from pysmlight.sse import MessageEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import SCAN_FIRMWARE_INTERVAL +from homeassistant.components.smlight.const import DOMAIN, SCAN_FIRMWARE_INTERVAL from homeassistant.components.update import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, @@ -27,7 +27,12 @@ from homeassistant.helpers import entity_registry as er from . import get_mock_event_function from .conftest import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) from tests.typing import WebSocketGenerator pytestmark = [ @@ -62,12 +67,14 @@ MOCK_FIRMWARE_FAIL = MessageEvent( MOCK_FIRMWARE_NOTES = [ Firmware( - ver="v2.3.6", + ver="v2.7.2", mode="ESP", notes=None, ) ] +MOCK_RADIO = Radio(chip_index=1, zb_channel=0, zb_type=0, zb_version="20240716") + @pytest.fixture def platforms() -> list[Platform]: @@ -103,7 +110,7 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -126,7 +133,7 @@ async def test_update_firmware( event_function(MOCK_FIRMWARE_DONE) mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(timedelta(seconds=5)) @@ -135,8 +142,50 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" + + +async def test_update_zigbee2_firmware( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test update of zigbee2 firmware where available.""" + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_zigbee_firmware_2" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240314" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) + + event_function(MOCK_FIRMWARE_DONE) + with patch( + "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO + ): + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( @@ -156,7 +205,7 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.0.18" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -172,7 +221,7 @@ async def test_update_legacy_firmware_v2( event_function(MOCK_FIRMWARE_DONE) mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(SCAN_FIRMWARE_INTERVAL) @@ -181,8 +230,8 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" async def test_update_firmware_failed( @@ -196,7 +245,7 @@ async def test_update_firmware_failed( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -233,7 +282,7 @@ async def test_update_reboot_timeout( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" with ( patch( @@ -267,18 +316,29 @@ async def test_update_reboot_timeout( mock_warning.assert_called_once() +@pytest.mark.parametrize( + "entity_id", + [ + "update.mock_title_core_firmware", + "update.mock_title_zigbee_firmware", + "update.mock_title_zigbee_firmware_2", + ], +) async def test_update_release_notes( hass: HomeAssistant, + entity_id: str, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test firmware release notes.""" + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) await setup_integration(hass, mock_config_entry) ws_client = await hass_ws_client(hass) await hass.async_block_till_done() - entity_id = "update.mock_title_core_firmware" state = hass.states.get(entity_id) assert state @@ -294,16 +354,30 @@ async def test_update_release_notes( result = await ws_client.receive_json() assert result["result"] is not None + +async def test_update_blank_release_notes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test firmware missing release notes.""" + + entity_id = "update.mock_title_core_firmware" mock_smlight_client.get_firmware_version.side_effect = None mock_smlight_client.get_firmware_version.return_value = MOCK_FIRMWARE_NOTES - freezer.tick(SCAN_FIRMWARE_INTERVAL) - async_fire_time_changed(hass) + await setup_integration(hass, mock_config_entry) + ws_client = await hass_ws_client(hass) await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + await ws_client.send_json( { - "id": 2, + "id": 1, "type": "update/release_notes", "entity_id": entity_id, } From e6e42af8b6dfe1d38c831d1e71465661f7b66204 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:50:49 +0100 Subject: [PATCH 46/94] Update ismartgate to 5.0.2 (#137394) --- homeassistant/components/gogogate2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index 40633537ddf..238c145302a 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.1"] + "requirements": ["ismartgate==5.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b643ebc3a87..090f35c404d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ iperf3==0.1.11 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 522ceac309d..d77c752dccd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1052,7 +1052,7 @@ iottycloud==0.3.0 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 From e134c862cd1e1c7432fc517e8e4ed449c59aa1b0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:51:24 +0100 Subject: [PATCH 47/94] Update discovergy30303 to 0.3.3 (#137396) --- homeassistant/components/steamist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index f79c2aaa99c..ab81c8b5a53 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 090f35c404d..ae27e0cca9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -785,7 +785,7 @@ directv==0.4.0 discogs-client==2.3.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d77c752dccd..29ff9381e9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -672,7 +672,7 @@ dio-chacon-wifi-api==1.2.1 directv==0.4.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 From b75a9d15d0af2a807be45a50a000bff999e0544a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 09:05:50 +0100 Subject: [PATCH 48/94] Fix spelling of "YoLink" and improve action descriptions (#137412) Make one occurrence of "yolink" consistent, using "YoLink" instead. Reword the descriptions of the play_on_speaker_hub action to improve translations by using more descriptive language. --- homeassistant/components/yolink/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index cbb092405d7..8ec7612fd73 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -6,7 +6,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The yolink integration needs to re-authenticate your account" + "description": "The YoLink integration needs to re-authenticate your account" } }, "abort": { @@ -99,11 +99,11 @@ "services": { "play_on_speaker_hub": { "name": "Play on SpeakerHub", - "description": "Convert text to audio play on YoLink SpeakerHub", + "description": "Converts text to speech for playback on a YoLink SpeakerHub", "fields": { "target_device": { - "name": "SpeakerHub Device", - "description": "SpeakerHub Device" + "name": "SpeakerHub device", + "description": "SpeakerHub device for audio playback." }, "message": { "name": "Text message", @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Override the speaker volume during playback of this message only." + "description": "Overrides the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", From 8349ea812512502306738c72ad4823d5d7f49720 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:09:33 +1000 Subject: [PATCH 49/94] Bump Tesla Fleet API to v0.9.8 (#137379) * v0.9.7 * v0.9.8 --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index fa0f336eb18..330745316d7 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6"] + "requirements": ["tesla-fleet-api==0.9.8"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 749bd7c4173..bfa0d831a16 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index f6015b0ef4e..ef4d366c779 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index ae27e0cca9f..b0069582032 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2857,7 +2857,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29ff9381e9e..f43382956ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2297,7 +2297,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 5ef3cad89ad84835bb93654a7941c7126fbb7597 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 19:14:32 +1100 Subject: [PATCH 50/94] Bump pysmlight to v0.2.3 (#137386) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index cec5d6a6d8b..3f527d1fcd9 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.2"], + "requirements": ["pysmlight==0.2.3"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index b0069582032..69d42585a52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2313,7 +2313,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f43382956ca..8086bdece39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1885,7 +1885,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 From 03de3aec1584ae3512f89c0a5fee96893865ee4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 09:21:52 +0100 Subject: [PATCH 51/94] Bump sigstore/cosign-installer from 3.7.0 to 3.8.0 (#137404) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.7.0 to 3.8.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/v3.7.0...v3.8.0) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index aa4bfc60c11..cdffcbe4d5b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.7.0 + uses: sigstore/cosign-installer@v3.8.0 with: cosign-release: "v2.2.3" From 3fc13db7e08c6949951d18fc0b83f236ce3d34f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 02:29:23 -0600 Subject: [PATCH 52/94] Fix memory leak when unloading DataUpdateCoordinator (#137338) * check wiz * Fix memory leak when unloading DataUpdateCoordinator fixes #137237 * handle namespace conflict * handle namespace conflict * address review comments --- homeassistant/helpers/update_coordinator.py | 29 +++++++++++++-------- tests/components/homewizard/test_init.py | 5 ++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 943eadff19a..be765ff422d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,6 +6,7 @@ from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Generator from datetime import datetime, timedelta +from functools import partial import logging from random import randint from time import monotonic @@ -103,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) - self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {} + self._last_listener_id: int = 0 self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -148,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.pop(remove_listener) - if not self._listeners: - self._unschedule_refresh() - - self._listeners[remove_listener] = (update_callback, context) + self._last_listener_id += 1 + self._listeners[self._last_listener_id] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - return remove_listener + return partial(self.__async_remove_listener_internal, self._last_listener_id) + + @callback + def __async_remove_listener_internal(self, listener_id: int) -> None: + """Remove a listener. + + This is an internal function that is not to be overridden + in subclasses as it may change in the future. + """ + self._listeners.pop(listener_id) + if not self._listeners: + self._unschedule_refresh() + self._debounced_refresh.async_cancel() @callback def async_update_listeners(self) -> None: diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 412ddb13eda..9139ef80d12 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock +import weakref from freezegun.api import FrozenDateTimeFactory from homewizard_energy.errors import DisabledError, UnauthorizedError @@ -25,6 +26,9 @@ async def test_load_unload_v1( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + weak_ref = weakref.ref(mock_config_entry.runtime_data) + assert weak_ref() is not None + assert mock_config_entry.state is ConfigEntryState.LOADED assert len(mock_homewizardenergy.combined.mock_calls) == 1 @@ -32,6 +36,7 @@ async def test_load_unload_v1( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert weak_ref() is None async def test_load_unload_v2( From 7ad0438a4e7cd1bb83e8669e67a54683ab03e5c9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:43:22 +1000 Subject: [PATCH 53/94] Handle powerwall at zero percent in Tesla Fleet and Tessie (#137393) * Handle powerwall zero * Add missing value_fn call --- .../components/tesla_fleet/sensor.py | 29 ++++++++++--------- homeassistant/components/tessie/sensor.py | 2 ++ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 3e05e7e723b..c1d38bf85c5 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -303,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( + TeslaFleetSensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -312,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -321,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -331,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -346,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -354,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -362,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -370,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -379,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="island_status", options=[ "island_status_unknown", @@ -550,12 +551,12 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity): class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity): """Base class for Tesla Fleet energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslaFleetSensorEntityDescription def __init__( self, data: TeslaFleetEnergyData, - description: SensorEntityDescription, + description: TeslaFleetSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -563,7 +564,7 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7f09cef2acd..323fa76ef1f 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), TessieSensorEntityDescription( key="battery_power", From bfbf95f51517157e441eb669f18616a6f6efcee0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 10:14:39 +0100 Subject: [PATCH 54/94] Allow creating backup if at least one agent is available (#137409) --- homeassistant/components/backup/manager.py | 61 +++++++++++++----- tests/components/backup/test_manager.py | 75 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fa9ca956c22..24639ad4008 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io +from itertools import chain import json from pathlib import Path, PurePath import shutil @@ -827,7 +828,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add(written_backup.backup, agent_errors, []) return written_backup.backup.backup_id async def async_create_backup( @@ -951,12 +952,23 @@ class BackupManager: with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" - if not agent_ids: - raise BackupManagerError("At least one agent must be selected") - if invalid_agents := [ + unavailable_agents = [ agent_id for agent_id in agent_ids if agent_id not in self.backup_agents - ]: - raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") + ] + if not ( + available_agents := [ + agent_id for agent_id in agent_ids if agent_id in self.backup_agents + ] + ): + raise BackupManagerError( + f"At least one available backup agent must be selected, got {agent_ids}" + ) + if unavailable_agents: + LOGGER.warning( + "Backup agents %s are not available, will backupp to %s", + unavailable_agents, + available_agents, + ) if include_all_addons and include_addons: raise BackupManagerError( "Cannot include all addons and specify specific addons" @@ -973,7 +985,7 @@ class BackupManager: new_backup, self._backup_task, ) = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, + agent_ids=available_agents, backup_name=backup_name, extra_metadata=extra_metadata | { @@ -992,7 +1004,9 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings, password), + self._async_finish_backup( + available_agents, unavailable_agents, with_automatic_settings, password + ), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -1009,7 +1023,11 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool, password: str | None + self, + available_agents: list[str], + unavailable_agents: list[str], + with_automatic_settings: bool, + password: str | None, ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -1028,7 +1046,7 @@ class BackupManager: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", written_backup.backup.backup_id, - agent_ids, + available_agents, ) self.async_on_backup_event( CreateBackupEvent( @@ -1041,13 +1059,15 @@ class BackupManager: try: agent_errors = await self._async_upload_backup( backup=written_backup.backup, - agent_ids=agent_ids, + agent_ids=available_agents, open_stream=written_backup.open_stream, password=password, ) finally: await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add( + written_backup.backup, agent_errors, unavailable_agents + ) if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup @@ -1056,7 +1076,7 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors) + self._update_issue_after_agent_upload(agent_errors, unavailable_agents) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1216,10 +1236,10 @@ class BackupManager: ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception] + self, agent_errors: dict[str, Exception], unavailable_agents: list[str] ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors: + if not agent_errors and not unavailable_agents: ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return ir.async_create_issue( @@ -1233,7 +1253,13 @@ class BackupManager: translation_key="automatic_backup_failed_upload_agents", translation_placeholders={ "failed_agents": ", ".join( - self.backup_agents[agent_id].name for agent_id in agent_errors + chain( + ( + self.backup_agents[agent_id].name + for agent_id in agent_errors + ), + unavailable_agents, + ) ) }, ) @@ -1302,11 +1328,12 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, - failed_agent_ids=list(agent_errors), + failed_agent_ids=list(chain(agent_errors, unavailable_agents)), ) self._manager.store.save() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 57f11ed4708..aa7d7ebd95c 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -359,8 +359,14 @@ async def test_create_backup_when_busy( @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -410,6 +416,8 @@ async def test_create_backup_wrong_parameters( "name", "expected_name", "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", "temp_file_unlink_call_count", ), [ @@ -419,6 +427,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -427,6 +437,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -435,6 +447,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], 0, ), ( @@ -443,6 +457,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -451,6 +467,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -459,6 +477,19 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], 0, ), ], @@ -486,6 +517,8 @@ async def test_initiate_backup( name: str | None, expected_name: str, expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -620,13 +653,13 @@ async def test_initiate_backup( "addons": [], "agents": { agent_id: {"protected": bool(password), "size": ANY} - for agent_id in agent_ids + for agent_id in expected_agent_ids }, "backup_id": backup_id, "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, - "failed_agent_ids": [], + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -959,6 +992,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", "agent_upload_side_effect", @@ -968,6 +1002,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -975,14 +1010,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + {}, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + } + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -990,6 +1049,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -1003,6 +1063,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -1010,6 +1071,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -1023,6 +1085,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -1030,6 +1093,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -1047,6 +1111,7 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, agent_upload_side_effect: Exception | None, @@ -1077,7 +1142,7 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json() From 4d2c46959e4c7042ce817ee48ca09c9901d3b556 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:15:25 +0100 Subject: [PATCH 55/94] Add coordinator unsubscribe listener test (#137422) --- tests/helpers/test_update_coordinator.py | 41 +++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 539762a60ff..3ad5754dada 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch import urllib.error +import weakref import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -12,7 +13,7 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -898,3 +899,41 @@ async def test_config_entry(hass: HomeAssistant) -> None: hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + + +async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: + """Test listener subscribe/unsubscribe releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + And https://github.com/home-assistant/core/pull/137338 + """ + + class Subscriber: + _unsub: CALLBACK_TYPE | None = None + + def start_listen( + self, coordinator: update_coordinator.DataUpdateCoordinator + ) -> None: + self._unsub = coordinator.async_add_listener(lambda: None) + + def stop_listen(self) -> None: + self._unsub() + self._unsub = None + + coordinator = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test" + ) + subscriber = Subscriber() + subscriber.start_listen(coordinator) + + # Keep weak reference to the coordinator + weak_ref = weakref.ref(coordinator) + assert weak_ref() is not None + + # Unload the subscriber, then shutdown the coordinator + subscriber.stop_listen() + await coordinator.async_shutdown() + del coordinator + + # Ensure the coordinator is released + assert weak_ref() is None From ac42c9386cb7e1a28695303f874280e8473d38a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 05:42:41 -0500 Subject: [PATCH 56/94] Simplify llm calendar tool (#137402) * Simplify calendar tool * Clean up exposed entities --- .../components/mcp_server/llm_api.py | 4 +- homeassistant/helpers/llm.py | 69 +++++++++++-------- tests/helpers/test_llm.py | 15 +++- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index f4292744815..5c29b29153e 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -35,13 +35,13 @@ class StatelessAssistAPI(llm.AssistAPI): """Return the prompt for the exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) entities = [ {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities.values() + for entity_info in exposed_entities["entities"].values() ] prompt.append(yaml_util.dump(list(entities))) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b7c4951d8de..2ef785e7f71 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -329,7 +329,7 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - if not exposed_entities: + if not exposed_entities or not exposed_entities["entities"]: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." @@ -392,11 +392,11 @@ class AssistAPI(API): """Return the prompt for the API for exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml_util.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) return prompt @@ -428,8 +428,9 @@ class AssistAPI(API): exposed_domains: set[str] | None = None if exposed_entities is not None: exposed_domains = { - split_entity_id(entity_id)[0] for entity_id in exposed_entities + info["domain"] for info in exposed_entities["entities"].values() } + intent_handlers = [ intent_handler for intent_handler in intent_handlers @@ -441,25 +442,29 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] - if exposed_domains and CALENDAR_DOMAIN in exposed_domains: - tools.append(CalendarGetEventsTool()) - if llm_context.assistant is not None: - for state in self.hass.states.async_all(SCRIPT_DOMAIN): - if not async_should_expose( - self.hass, llm_context.assistant, state.entity_id - ): - continue + if exposed_entities: + if exposed_entities[CALENDAR_DOMAIN]: + names = [] + for info in exposed_entities[CALENDAR_DOMAIN].values(): + names.extend(info["names"].split(", ")) + tools.append(CalendarGetEventsTool(names)) - tools.append(ScriptTool(self.hass, state.entity_id)) + tools.extend( + ScriptTool(self.hass, script_entity_id) + for script_entity_id in exposed_entities[SCRIPT_DOMAIN] + ) return tools def _get_exposed_entities( hass: HomeAssistant, assistant: str -) -> dict[str, dict[str, Any]]: - """Get exposed entities.""" +) -> dict[str, dict[str, dict[str, Any]]]: + """Get exposed entities. + + Splits out calendars and scripts. + """ area_registry = ar.async_get(hass) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -480,12 +485,13 @@ def _get_exposed_entities( } entities = {} + data: dict[str, dict[str, Any]] = { + SCRIPT_DOMAIN: {}, + CALENDAR_DOMAIN: {}, + } for state in hass.states.async_all(): - if ( - not async_should_expose(hass, assistant, state.entity_id) - or state.domain == SCRIPT_DOMAIN - ): + if not async_should_expose(hass, assistant, state.entity_id): continue description: str | None = None @@ -532,9 +538,13 @@ def _get_exposed_entities( }: info["attributes"] = attributes - entities[state.entity_id] = info + if state.domain in data: + data[state.domain][state.entity_id] = info + else: + entities[state.entity_id] = info - return entities + data["entities"] = entities + return data def _selector_serializer(schema: Any) -> Any: # noqa: C901 @@ -816,15 +826,18 @@ class CalendarGetEventsTool(Tool): name = "calendar_get_events" description = ( "Get events from a calendar. " - "When asked when something happens, search the whole week. " + "When asked if something happens, search the whole week. " "Results are RFC 5545 which means 'end' is exclusive." ) - parameters = vol.Schema( - { - vol.Required("calendar"): cv.string, - vol.Required("range"): vol.In(["today", "week"]), - } - ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index e288026b67b..630ed3f4fa1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1170,7 +1170,9 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) - hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + hass.states.async_set( + "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} + ) async_expose_entity(hass, "conversation", "calendar.test_calendar", True) context = Context() llm_context = llm.LLMContext( @@ -1182,7 +1184,11 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + tool = next( + (tool for tool in api.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] calls = async_mock_service( hass, @@ -1212,7 +1218,10 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: tool_input = llm.ToolInput( tool_name="calendar_get_events", - tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + tool_args={ + "calendar": "Mock Calendar Name", + "range": "today", + }, ) now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): From 27b96160e2706b45e085f487ab059143cddcc973 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:02:58 +0100 Subject: [PATCH 57/94] Adjust backup filename scheme (#137424) * Adjust backup filename scheme * Update tests --- homeassistant/components/backup/util.py | 2 +- tests/components/backup/test_backup.py | 4 +--- tests/components/backup/test_manager.py | 14 +++++++------- tests/components/backup/test_util.py | 8 ++++---- .../google_drive/snapshots/test_backup.ambr | 4 ++-- tests/components/hassio/test_backup.py | 4 ++-- tests/components/synology_dsm/test_backup.py | 6 +++--- 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index b920c66a9b8..9d8f6e815dc 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -122,7 +122,7 @@ def read_backup(backup_path: Path) -> AgentBackup: def suggested_filename_from_name_date(name: str, date_str: str) -> str: """Suggest a filename for the backup.""" date = dt_util.parse_datetime(date_str, raise_on_error=True) - return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) def suggested_filename(backup: AgentBackup) -> str: diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c441cae292c..38b61ce65ea 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,9 +103,7 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert ( - move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" - ) + assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar" @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index aa7d7ebd95c..608b54eefcc 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -426,7 +426,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -446,7 +446,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -456,7 +456,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -476,7 +476,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -487,7 +487,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], ["test.unavailable"], 0, @@ -1676,7 +1676,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1685,7 +1685,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3b188ff8226..504e0d56d58 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -529,10 +529,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("name", "resulting_filename"), [ - ("test", "test_-_2025-01-30_13.42_12345678.tar"), - (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), - ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), - ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ("test", "test_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"), ], ) def test_suggested_filename(name: str, resulting_filename: str) -> None: diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 9e1ec00b52e..2f3df3eed7f 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -140,7 +140,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'HA folder ID', ]), @@ -211,7 +211,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'new folder id', ]), diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 496dc93df32..6b6163acb15 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -887,7 +887,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), + filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -1400,7 +1400,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 26e09d407ff..ea68bbc991c 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,7 +36,7 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator -BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" class MockStreamReaderChunked(MockStreamReader): @@ -525,7 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" with ( patch( @@ -576,7 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" # fail to upload the tar file with ( From 28cedc4c13bb1421bd4867d7136e7be72d9c32f9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 12:13:58 +0100 Subject: [PATCH 58/94] Improve action descriptions in rainmachine, fix casing (#137428) --- homeassistant/components/rainmachine/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index a564d33e777..aad61458e88 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -5,7 +5,7 @@ "user": { "title": "Fill in your information", "data": { - "ip_address": "Hostname or IP Address", + "ip_address": "Hostname or IP address", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } @@ -157,7 +157,7 @@ }, "unpause_watering": { "name": "Unpause all watering", - "description": "Unpauses all paused watering activities.", + "description": "Resumes all paused watering activities.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -167,7 +167,7 @@ }, "push_flow_meter_data": { "name": "Push flow meter data", - "description": "Push flow meter data to the RainMachine device.", + "description": "Sends flow meter data from Home Assistant to the RainMachine device.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -185,7 +185,7 @@ }, "push_weather_data": { "name": "Push weather data", - "description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", + "description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -193,7 +193,7 @@ }, "timestamp": { "name": "Timestamp", - "description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." + "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { "name": "Min temp", @@ -251,7 +251,7 @@ }, "unrestrict_watering": { "name": "Unrestrict all watering", - "description": "Unrestrict all watering activities.", + "description": "Removes all watering restrictions.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", From 2f116eab9ee404a9ded161248e7aa4469e03c42b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:14:06 +0100 Subject: [PATCH 59/94] Adjust logic for per-backup agent encryption (#137420) --- homeassistant/components/backup/manager.py | 6 ++- tests/components/backup/test_manager.py | 55 +++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 24639ad4008..25393a872cc 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1439,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] agent_config = manager.config.data.agents.get(self._local_agent_id) - if agent_config and not agent_config.protected: + if ( + self._local_agent_id in agent_ids + and agent_config + and not agent_config.protected + ): password = None backup = AgentBackup( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 608b54eefcc..bdcb9f068b6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -46,6 +46,7 @@ from homeassistant.components.backup.manager import ( RestoreBackupState, WrittenBackup, ) +from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3207,17 +3208,21 @@ async def test_restore_backup_file_error( @pytest.mark.parametrize( - ("commands", "password", "protected_backup"), + ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"), [ ( [], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, ), ( [], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3229,8 +3234,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": False}, + None, # None of the agents are protected ), ( [ @@ -3242,8 +3249,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": True}, + None, # Local agent is not protected ), ( [ @@ -3255,8 +3264,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": False}, + password_to_key("hunter2"), # Local agent is protected ), ( [ @@ -3268,8 +3279,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3281,8 +3294,40 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, # No password supplied + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": False}, + password_to_key("hunter2"), # Temporary backup protected when password set ), ], ) @@ -3291,13 +3336,15 @@ async def test_initiate_backup_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, + mocked_tarfile: Mock, path_glob: MagicMock, commands: dict[str, Any], + agent_ids: list[str], password: str | None, protected_backup: dict[str, bool], + inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - agent_ids = ["backup.local", "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -3373,6 +3420,10 @@ async def test_initiate_backup_per_agent_encryption( await hass.async_block_till_done() + mocked_tarfile.return_value.create_inner_tar.assert_called_once_with( + ANY, gzip=True, key=inner_tar_key + ) + result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, From a1655d28ba3819551b90c17a736aee662f530dda Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 12:14:40 +0100 Subject: [PATCH 60/94] Fix sentence-casing and action names/descriptions in unifiprotect (#137418) --- .../components/unifiprotect/strings.json | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index cde8c88d169..d5a7d615399 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -3,8 +3,8 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "UniFi Protect Setup", - "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect setup", + "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -17,17 +17,17 @@ } }, "reauth_confirm": { - "title": "UniFi Protect Reauth", + "title": "UniFi Protect reauth", "data": { - "host": "IP/Host of UniFi Protect Server", + "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, "discovery_confirm": { - "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect discovered", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -38,7 +38,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", - "cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user." + "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -49,12 +49,12 @@ "options": { "step": { "init": { - "title": "UniFi Protect Options", + "title": "UniFi Protect options", "description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If not enabled, they will only update once every 15 minutes.", "data": { "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", - "override_connection_host": "Override Connection Host", + "override_connection_host": "Override connection host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } @@ -68,7 +68,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", @@ -123,8 +123,8 @@ } }, "deprecate_hdr_switch": { - "title": "HDR Mode Switch Deprecated", - "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." + "title": "HDR Mode switch deprecated", + "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode switch has been replaced with an HDR Mode select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." } }, "entity": { @@ -171,22 +171,22 @@ }, "services": { "add_doorbell_text": { - "name": "Add custom doorbell text", + "name": "Add doorbell text", "description": "Adds a new custom message for doorbells.", "fields": { "device_id": { "name": "UniFi Protect NVR", - "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances." + "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect instances." }, "message": { "name": "Custom message", - "description": "New custom message to add for doorbells. Must be less than 30 characters." + "description": "New custom message to add. Must be less than 30 characters." } } }, "remove_doorbell_text": { - "name": "Remove custom doorbell text", - "description": "Removes an existing message for doorbells.", + "name": "Remove doorbell text", + "description": "Removes an existing custom message for doorbells.", "fields": { "device_id": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", @@ -194,13 +194,13 @@ }, "message": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::message::name%]", - "description": "Existing custom message to remove for doorbells." + "description": "Existing custom message to remove." } } }, "set_chime_paired_doorbells": { "name": "Set chime paired doorbells", - "description": "Use to set the paired doorbell(s) with a smart chime.", + "description": "Pairs doorbell(s) with a smart chime.", "fields": { "device_id": { "name": "Chime", @@ -213,22 +213,22 @@ } }, "remove_privacy_zone": { - "name": "Remove camera privacy zone", - "description": "Use to remove a privacy zone from a camera.", + "name": "Remove privacy zone", + "description": "Removes a privacy zone from a camera.", "fields": { "device_id": { "name": "Camera", - "description": "Camera you want to remove privacy zone from." + "description": "Camera you want to remove the privacy zone from." }, "name": { - "name": "Privacy Zone Name", + "name": "Privacy zone", "description": "The name of the zone to remove." } } }, "get_user_keyring_info": { - "name": "Retrieve Keyring Details for Users", - "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.", + "name": "Get user keyring info", + "description": "Fetches a detailed list of users with NFC and fingerprint associations for automations.", "fields": { "device_id": { "name": "UniFi Protect NVR", From 1445e175219ce22f5b0d93c3bbff6e1f44c5e8b4 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Feb 2025 20:18:14 +0900 Subject: [PATCH 61/94] Bump thinqconnect to 1.0.4 (#137395) Co-authored-by: yunseon.park --- .../components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/air_conditioner/profile.json | 74 +++++++++++++++++++ .../fixtures/air_conditioner/status.json | 17 +++++ .../lg_thinq/snapshots/test_climate.ambr | 6 +- .../lg_thinq/snapshots/test_sensor.ambr | 47 ++++++++++++ 7 files changed, 145 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 6dd60909c66..b00d28c1d4f 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.2"] + "requirements": ["thinqconnect==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69d42585a52..e00a1bb8e4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2884,7 +2884,7 @@ thermopro-ble==0.11.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8086bdece39..a4fd83431b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2318,7 +2318,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 0d45dc5c9f4..85ce95da0ed 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -57,6 +57,16 @@ "type": "number" } }, + "filterInfo": { + "filterLifetime": { + "mode": ["r"], + "type": "number" + }, + "usedTime": { + "mode": ["r"], + "type": "number" + } + }, "operation": { "airCleanOperationMode": { "mode": ["w"], @@ -124,6 +134,52 @@ } } }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ], "timer": { "relativeHourToStart": { "mode": ["r", "w"], @@ -149,6 +205,24 @@ "mode": ["r", "w"], "type": "number" } + }, + "windDirection": { + "rotateUpDown": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + }, + "rotateLeftRight": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + } } } } diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json index 90d15d1ae16..8440e7da28c 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/status.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -32,6 +32,19 @@ "targetTemperature": 19, "unit": "C" }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ], + "timer": { "relativeStartTimer": "UNSET", "relativeStopTimer": "UNSET", @@ -39,5 +52,9 @@ "absoluteStopTimer": "UNSET", "absoluteHourToStart": 13, "absoluteMinuteToStart": 14 + }, + "windDirection": { + "rotateUpDown": false, + "rotateLeftRight": false } } diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e9470c3de03..9369367a1f7 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -43,7 +43,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -72,7 +72,9 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, 'target_temp_step': 1, 'temperature': 19, }), diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 2c58b109e61..fe1929944f9 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter remaining', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_filter_lifetime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Filter remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540', + }) +# --- # name: test_all_entities[sensor.test_air_conditioner_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 83edee47ff44e93200d65f645a21370b2e24bd0c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 13:09:22 +0100 Subject: [PATCH 62/94] Replace wrong name reference of binary jvc_power sensor in jvc_projector (#137271) --- homeassistant/components/jvc_projector/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index b517bf064e1..c6e5736bd2d 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -36,7 +36,7 @@ "entity": { "binary_sensor": { "jvc_power": { - "name": "[%key:component::sensor::entity_component::power::name%]" + "name": "[%key:component::binary_sensor::entity_component::power::name%]" } }, "select": { From 41490dffad0d7bbaeca2b5e445147e3a1aa225b1 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:09:59 -0500 Subject: [PATCH 63/94] Bump lacrosse-view to 1.1.1 (#137282) --- homeassistant/components/lacrosse_view/manifest.json | 2 +- homeassistant/components/lacrosse_view/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 86b2f61a872..38e64274deb 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], - "requirements": ["lacrosse-view==1.0.4"] + "requirements": ["lacrosse-view==1.1.1"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index b2ad9672504..fceddeb9b2c 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -45,7 +45,7 @@ class LaCrosseSensorEntityDescription(SensorEntityDescription): def get_value(sensor: Sensor, field: str) -> float | int | str | None: """Get the value of a sensor field.""" - field_data = sensor.data.get(field) + field_data = sensor.data.get(field) if sensor.data is not None else None if field_data is None: return None value = field_data["values"][-1]["s"] @@ -178,7 +178,7 @@ async def async_setup_entry( continue # if the API returns a different unit of measurement from the description, update it - if sensor.data.get(field) is not None: + if sensor.data is not None and sensor.data.get(field) is not None: native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( sensor.data[field].get("unit") ) @@ -240,7 +240,9 @@ class LaCrosseViewSensor( @property def available(self) -> bool: """Return True if entity is available.""" + data = self.coordinator.data[self.index].data return ( super().available - and self.entity_description.key in self.coordinator.data[self.index].data + and data is not None + and self.entity_description.key in data ) diff --git a/requirements_all.txt b/requirements_all.txt index e00a1bb8e4f..a5217c42236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1284,7 +1284,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.eufy lakeside==0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4fd83431b1..e3b233ba8e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1086,7 +1086,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.laundrify laundrify-aio==1.2.2 From fcb8d25b461db55fa26a0d90ce9b9ceeb886731e Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:40:33 +0100 Subject: [PATCH 64/94] Show new errors from the MotionMount (#137006) --- .../components/motionmount/sensor.py | 31 ++++++++---- .../components/motionmount/strings.json | 3 ++ tests/components/motionmount/test_sensor.py | 48 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 tests/components/motionmount/test_sensor.py diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 8e55fad4a8b..685c3ebf932 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -1,6 +1,9 @@ """Support for MotionMount sensors.""" +from typing import Final + import motionmount +from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant @@ -9,6 +12,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity +ERROR_MESSAGES: Final = { + MotionMountSystemError.MotorError: "motor", + MotionMountSystemError.ObstructionDetected: "obstruction", + MotionMountSystemError.TVWidthConstraintError: "tv_width_constraint", + MotionMountSystemError.HDMICECError: "hdmi_cec", + MotionMountSystemError.InternalError: "internal", +} + async def async_setup_entry( hass: HomeAssistant, @@ -25,7 +36,14 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): """The error status sensor of a MotionMount.""" _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["none", "motor", "internal"] + _attr_options = [ + "none", + "motor", + "hdmi_cec", + "obstruction", + "tv_width_constraint", + "internal", + ] _attr_translation_key = "motionmount_error_status" def __init__( @@ -38,13 +56,10 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): @property def native_value(self) -> str: """Return error status.""" - errors = self.mm.error_status or 0 + status = self.mm.system_status - if errors & (1 << 31): - # Only when but 31 is set are there any errors active at this moment - if errors & (1 << 10): - return "motor" - - return "internal" + for error, message in ERROR_MESSAGES.items(): + if error in status: + return message return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 1fcb6c47c99..75fd0773322 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -72,6 +72,9 @@ "state": { "none": "None", "motor": "Motor", + "hdmi_cec": "HDMI CEC", + "obstruction": "Obstruction", + "tv_width_constraint": "TV width constraint", "internal": "Internal" } } diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py new file mode 100644 index 00000000000..bb68c67ce62 --- /dev/null +++ b/tests/components/motionmount/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the MotionMount Sensor platform.""" + +from unittest.mock import patch + +from motionmount import MotionMountSystemError +import pytest + +from homeassistant.core import HomeAssistant + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") + + +@pytest.mark.parametrize( + ("system_status", "state"), + [ + (None, "none"), + (MotionMountSystemError.MotorError, "motor"), + (MotionMountSystemError.ObstructionDetected, "obstruction"), + (MotionMountSystemError.TVWidthConstraintError, "tv_width_constraint"), + (MotionMountSystemError.HDMICECError, "hdmi_cec"), + (MotionMountSystemError.InternalError, "internal"), + ], +) +async def test_error_status_sensor_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + system_status: MotionMountSystemError, + state: str, +) -> None: + """Tests the state attributes.""" + with patch( + "homeassistant.components.motionmount.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + motionmount_mock.return_value.name = ZEROCONF_NAME + motionmount_mock.return_value.mac = MAC + motionmount_mock.return_value.is_authenticated = True + motionmount_mock.return_value.system_status = [system_status] + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert hass.states.get("sensor.my_motionmount_error_status").state == state From 0236f2434e190828038fd23896b29baf5480b05b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 5 Feb 2025 13:48:12 +0100 Subject: [PATCH 65/94] Bump reolink_aio to 0.11.9 (#137430) * Add push callbacks * Bump reolink_aio to 0.11.9 --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 5 +++++ homeassistant/components/reolink/select.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 83729fef3cd..fb3c096ee41 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.8"] + "requirements": ["reolink-aio==0.11.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e4b52c85d45..d8fabfaa3b8 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -424,6 +424,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_brightness", cmd_key="GetImage", + cmd_id=26, translation_key="image_brightness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -437,6 +438,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_contrast", cmd_key="GetImage", + cmd_id=26, translation_key="image_contrast", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -450,6 +452,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_saturation", cmd_key="GetImage", + cmd_id=26, translation_key="image_saturation", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -463,6 +466,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_sharpness", cmd_key="GetImage", + cmd_id=26, translation_key="image_sharpness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -476,6 +480,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_hue", cmd_key="GetImage", + cmd_id=26, translation_key="image_hue", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7a74be2e28c..df8c0269957 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -80,6 +80,7 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", + cmd_id=26, translation_key="day_night_mode", entity_category=EntityCategory.CONFIG, get_options=[mode.name for mode in DayNightEnum], diff --git a/requirements_all.txt b/requirements_all.txt index a5217c42236..1254c7521f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2606,7 +2606,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3b233ba8e4..758365eeeb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2109,7 +2109,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.rflink rflink==0.0.66 From eded99a0591c6627719f05ab0a5844cda4701cd4 Mon Sep 17 00:00:00 2001 From: Mick Montorier-Aberman Date: Wed, 5 Feb 2025 13:51:27 +0100 Subject: [PATCH 66/94] Add tests for sensors in SwitchBot Cloud (#137319) --- tests/components/switchbot_cloud/__init__.py | 4 +- .../fixtures/meter_status.json | 9 + .../snapshots/test_sensor.ambr | 307 ++++++++++++++++++ .../components/switchbot_cloud/test_button.py | 10 +- tests/components/switchbot_cloud/test_init.py | 10 +- tests/components/switchbot_cloud/test_lock.py | 4 +- .../components/switchbot_cloud/test_sensor.py | 65 ++++ .../components/switchbot_cloud/test_switch.py | 15 +- 8 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 tests/components/switchbot_cloud/fixtures/meter_status.json create mode 100644 tests/components/switchbot_cloud/snapshots/test_sensor.ambr create mode 100644 tests/components/switchbot_cloud/test_sensor.py diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index ce570499b3a..42fe3e4f543 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def configure_integration(hass: HomeAssistant) -> MockConfigEntry: +async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_API_TOKEN: "test-token", @@ -17,5 +17,7 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" ) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json new file mode 100644 index 00000000000..8b5bcd0c031 --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/meter_status.json @@ -0,0 +1,9 @@ +{ + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a9b6fb20bfb --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,307 @@ +# serializer version: 1 +# name: test_meter[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_meter[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.8', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index df5b7569100..0779e54ee03 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -28,10 +28,7 @@ async def test_pressmode_bot( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "button.bot_1" @@ -63,9 +60,6 @@ async def test_switchmode_bot_no_button_entity( mock_get_status.return_value = {"deviceMode": "switchMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index d5728faf369..f4837c4e97e 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -64,9 +64,7 @@ async def test_setup_entry_success( ), ] mock_get_status.return_value = {"power": PowerState.ON.value} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -91,8 +89,7 @@ async def test_setup_entry_fails_when_listing_devices( ) -> None: """Test error handling when list_devices in setup of entry.""" mock_list_devices.side_effect = error - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state == state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -114,8 +111,7 @@ async def test_setup_entry_fails_when_refreshing( ) ] mock_get_status.side_effect = CannotConnect - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index a09d7241794..fcb81abfc51 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -26,9 +26,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> mock_get_status.return_value = {"lockState": "locked"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py new file mode 100644 index 00000000000..6b0a52800f3 --- /dev/null +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -0,0 +1,65 @@ +"""Test for the switchbot_cloud sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device +from syrupy import SnapshotAssertion + +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + +from tests.common import load_json_object_fixture, snapshot_platform + + +async def test_meter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test Meter sensors.""" + + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = load_json_object_fixture("meter_status.json", DOMAIN) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_meter_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index b1c6fb81b96..99e0f50aa53 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -34,10 +34,7 @@ async def test_relay_switch( mock_get_status.return_value = {"switchStatus": 0} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.relay_switch_1" @@ -71,10 +68,7 @@ async def test_switchmode_bot( mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.bot_1" @@ -108,9 +102,6 @@ async def test_pressmode_bot_no_switch_entity( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(SWITCH_DOMAIN) From 417a595b73946634a73790a50b36bb9d37e357b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 08:25:05 -0500 Subject: [PATCH 67/94] Only clean up chat log if it was stored (#137399) --- .../components/conversation/chat_log.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 53e248d0a98..ad7a9d0ce9e 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -43,13 +43,6 @@ def async_get_chat_log( else: history = ChatLog(hass, session.conversation_id) - @callback - def do_cleanup() -> None: - """Handle cleanup.""" - all_history.pop(session.conversation_id) - - session.async_on_cleanup(do_cleanup) - if user_input is not None: history.async_add_user_content(UserContent(content=user_input.text)) @@ -63,6 +56,15 @@ def async_get_chat_log( ) return + if session.conversation_id not in all_history: + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_history.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + all_history[session.conversation_id] = history From c4411914c2e906105b765c00af5740bd0880e946 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 14:30:31 +0100 Subject: [PATCH 68/94] Replace "Ota" with "OTA update" in anova integration (#137431) --- homeassistant/components/anova/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index bfe3a61282e..e9905e4cce5 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -39,7 +39,7 @@ "idle": "[%key:common::state::idle%]", "cook": "Cooking", "low_water": "Low water", - "ota": "Ota", + "ota": "OTA update", "provisioning": "Provisioning", "high_temp": "High temperature", "device_failure": "Device failure" From 4d7bd1291d2fe7c85e243aa4d05e6620fe41ad4b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Feb 2025 15:26:58 +0100 Subject: [PATCH 69/94] Bump onedrive to 0.0.8 (#137423) * Bump onedrive to 0.0.6 * bump to 0.0.7 * bump to 0.0.8 * Improve coverage --- homeassistant/components/onedrive/__init__.py | 14 +++++--- homeassistant/components/onedrive/api.py | 34 ------------------- homeassistant/components/onedrive/backup.py | 4 +-- .../components/onedrive/config_flow.py | 9 +++-- .../components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onedrive/conftest.py | 30 +++++++++------- tests/components/onedrive/test_config_flow.py | 5 +++ tests/components/onedrive/test_init.py | 5 +++ 10 files changed, 46 insertions(+), 61 deletions(-) delete mode 100644 homeassistant/components/onedrive/api.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index ef7ddd04da6..5feefb2cf7d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,7 +25,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .api import OneDriveConfigEntryAccessTokenProvider from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -31,7 +33,7 @@ class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" client: OneDriveClient - token_provider: OneDriveConfigEntryAccessTokenProvider + token_function: Callable[[], Awaitable[str]] backup_folder_id: str @@ -46,9 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - token_provider = OneDriveConfigEntryAccessTokenProvider(session) + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) - client = OneDriveClient(token_provider, async_get_clientsession(hass)) + client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: @@ -81,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.runtime_data = OneDriveRuntimeData( client=client, - token_provider=token_provider, + token_function=get_access_token, backup_folder_id=backup_folder.id, ) diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py deleted file mode 100644 index d8f6ea188f3..00000000000 --- a/homeassistant/components/onedrive/api.py +++ /dev/null @@ -1,34 +0,0 @@ -"""API for OneDrive bound to Home Assistant OAuth.""" - -from typing import cast - -from onedrive_personal_sdk import TokenProvider - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow - - -class OneDriveConfigFlowAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, token: str) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._token = token - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return self._token - - -class OneDriveConfigEntryAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._oauth_session = oauth_session - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 43eac020538..78bdcb24b8c 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -109,7 +109,7 @@ class OneDriveBackupAgent(BackupAgent): self._hass = hass self._entry = entry self._client = entry.runtime_data.client - self._token_provider = entry.runtime_data.token_provider + self._token_function = entry.runtime_data.token_function self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -145,7 +145,7 @@ class OneDriveBackupAgent(BackupAgent): ) try: item = await LargeFileUploadClient.upload( - self._token_provider, file, session=async_get_clientsession(self._hass) + self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: raise BackupAgentError( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index cbdf59648b9..900db0177d9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -36,12 +35,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - token_provider = OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) + + async def get_access_token() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) graph_client = OneDriveClient( - token_provider, async_get_clientsession(self.hass) + get_access_token, async_get_clientsession(self.hass) ) try: diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 47eb48742be..88d51e6d73a 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.4"] + "requirements": ["onedrive-personal-sdk==0.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1254c7521f5..87cb85303ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 758365eeeb7..e7706013267 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1307,7 +1307,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index e76ce1d01c8..0d6ee09d587 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -67,8 +67,8 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(autouse=True) -def mock_onedrive_client() -> Generator[MagicMock]: +@pytest.fixture +def mock_onedrive_client_init() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( @@ -80,19 +80,25 @@ def mock_onedrive_client() -> Generator[MagicMock]: new=onedrive_client, ), ): - client = onedrive_client.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER - client.list_drive_items.return_value = [MOCK_BACKUP_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + yield onedrive_client - class MockStreamReader: - async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: - yield b"backup data" - client.download_drive_item.return_value = MockStreamReader() +@pytest.fixture(autouse=True) +def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + client = mock_onedrive_client_init.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - yield client + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_drive_item.return_value = MockStreamReader() + + return client @pytest.fixture diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 9acfd8ada3c..fb0d58b86c6 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -70,6 +70,7 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, + mock_onedrive_client_init: MagicMock, ) -> None: """Check full flow.""" @@ -79,6 +80,10 @@ async def test_full_flow( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 674bc2d38d9..a6ad55442aa 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -16,10 +16,15 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_onedrive_client_init: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) From 4613469eb7770f7e603460479d215f1c3272de07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 15:28:30 +0100 Subject: [PATCH 70/94] Fix spelling of "PowerView Hub" in user-facing strings (#137435) --- homeassistant/components/hunterdouglas_powerview/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index a107e2c5be4..231270d6eef 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -5,7 +5,7 @@ "title": "Connect to the PowerView Hub", "data": { "host": "[%key:common::config_flow::data::ip%]", - "api_version": "Hub Generation" + "api_version": "Hub generation" }, "data_description": { "api_version": "API version is detectable, but you can override and force a specific version" @@ -19,7 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unsupported_device": "Only the primary powerview hub can be added", + "unsupported_device": "Only the primary PowerView Hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From cc59f5812b7a19a8581ffb00667e161ab6ba3f8a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:36:05 +0100 Subject: [PATCH 71/94] Disable annotations for pytest warnings [ci] (#137434) --- .github/workflows/ci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 863c861db75..2a9f1571830 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -975,6 +975,7 @@ jobs: ${cov_params[@]} \ -o console_output_style=count \ -p no:sugar \ + --exclude-warning-annotations \ $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt) \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output @@ -1098,6 +1099,7 @@ jobs: -o console_output_style=count \ --durations=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1228,6 +1230,7 @@ jobs: --durations=0 \ --durations-min=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1374,6 +1377,7 @@ jobs: --durations=0 \ --durations-min=1 \ -p no:sugar \ + --exclude-warning-annotations \ tests/components/${{ matrix.group }} \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output From 86a4f7188dd28223ba58fb76a16ecc9f1c5a2fe8 Mon Sep 17 00:00:00 2001 From: Arkadiusz Wahlig Date: Wed, 5 Feb 2025 16:32:05 +0100 Subject: [PATCH 72/94] Update PySwitchbot to 0.56.0 (#137432) --- 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 1b80da43e16..92a1c25d6f5 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.55.4"] + "requirements": ["PySwitchbot==0.56.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 87cb85303ce..1f0c440e76b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7706013267..ef4ac360d50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From d48d4284c50f46f6eca9a83ebd3124c0c7875c45 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 6 Feb 2025 01:48:50 +1000 Subject: [PATCH 73/94] Add streaming select entities to Teslemetry (#137210) --- homeassistant/components/teslemetry/select.py | 275 ++++++++++++------ .../teslemetry/snapshots/test_select.ambr | 75 +++++ tests/components/teslemetry/test_select.py | 47 ++- 3 files changed, 305 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index baf1d80ac6c..d2e90a4f5c9 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -2,18 +2,27 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain +from typing import Any +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -24,53 +33,136 @@ HIGH = "high" PARALLEL_UPDATES = 0 +LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} + @dataclass(frozen=True, kw_only=True) -class SeatHeaterDescription(SelectEntityDescription): +class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - position: Seat - available_fn: Callable[[TeslemetrySeatHeaterSelectEntity], bool] = lambda _: True + select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + supported_fn: Callable[[dict], bool] = lambda _: True + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + | None + ) = None + options: list[str] -SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( - SeatHeaterDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_left", - position=Seat.FRONT_LEFT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_LEFT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_right", - position=Seat.FRONT_RIGHT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_RIGHT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_left", - position=Seat.REAR_LEFT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_LEFT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearLeft(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_center", - position=Seat.REAR_CENTER, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_CENTER, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearCenter(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_right", - position=Seat.REAR_RIGHT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_RIGHT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearRight(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_left", - position=Seat.THIRD_LEFT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_LEFT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_right", - position=Seat.THIRD_RIGHT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_RIGHT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_steering_wheel_heat_level", + select_fn=lambda api, level: api.remote_steering_wheel_heat_level_request( + level + ), + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatLevel(y), + options=[ + OFF, + LOW, + HIGH, + ], ), ) @@ -85,17 +177,18 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetrySeatHeaterSelectEntity( + TeslemetryPollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - for description in SEAT_HEATER_DESCRIPTIONS + if vehicle.api.pre2021 + or vehicle.firmware < "2024.26" + or description.streaming_listener is None + else TeslemetryStreamingSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in VEHICLE_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles - if description.key in vehicle.coordinator.data - ), - ( - TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") + if description.supported_fn(vehicle.coordinator.data) ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -112,22 +205,31 @@ async def async_setup_entry( ) -class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle seat heater.""" +class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): + """Parent vehicle select entity class.""" - entity_description: SeatHeaterDescription + entity_description: TeslemetrySelectEntityDescription + _climate: bool = False - _attr_options = [ - OFF, - LOW, - MEDIUM, - HIGH, - ] + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + level = LEVEL[option] + # AC must be on to turn on heaters + if level and not self._climate: + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command(self.entity_description.select_fn(self.api, level)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): + """Base polling vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, - description: SeatHeaterDescription, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" @@ -137,72 +239,63 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" - self._attr_available = self.entity_description.available_fn(self) - value = self._value - if not isinstance(value, int): + self._climate = bool(self.get("climate_state_is_climate_on")) + if not isinstance(self._value, int): self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on seat heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_seat_heater_request(self.entity_description.position, level) - ) - self._attr_current_option = option - self.async_write_ha_state() + self._attr_current_option = self.entity_description.options[self._value] -class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle steering wheel heater.""" - - _attr_options = [ - OFF, - LOW, - HIGH, - ] +class TeslemetryStreamingSelectEntity( + TeslemetryVehicleStreamEntity, TeslemetrySelectEntity, RestoreEntity +): + """Base streaming vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: - """Initialize the vehicle steering wheel select entity.""" + """Initialize the vehicle seat select entity.""" + self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes - super().__init__( - data, - "climate_state_steering_wheel_heat_level", + self._attr_current_option = None + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state in self.entity_description.options: + self._attr_current_option = state.state + + # Listen for streaming data + assert self.entity_description.streaming_listener is not None + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) ) - def _async_update_attrs(self) -> None: - """Handle updated data from the coordinator.""" + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled(self._climate_callback) + ) - value = self._value - if not isinstance(value, int): + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + if value is None: self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on steering wheel heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_steering_wheel_heat_level_request(level) - ) - self._attr_current_option = option + self._attr_current_option = self.entity_description.options[value] self.async_write_ha_state() + def _climate_callback(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._climate = bool(value) + class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): """Select entity for operation mode select entities.""" diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 0c2547f309d..90af1259273 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -408,3 +408,78 @@ 'state': 'off', }) # --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_streaming[select.test_seat_heater_front_left] + 'off' +# --- +# name: test_select_streaming[select.test_seat_heater_front_right] + 'low' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_center] + 'unknown' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_left] + 'medium' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_right] + 'high' +# --- +# name: test_select_streaming[select.test_steering_wheel_heater] + 'off' +# --- diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 005a6a2004e..c49e83803cd 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from teslemetry_stream.const import Signal from homeassistant.components.select import ( ATTR_OPTION, @@ -16,7 +17,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -25,6 +26,7 @@ async def test_select( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities are correct.""" @@ -106,6 +108,7 @@ async def test_select_invalid_data( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities handle invalid data.""" @@ -119,3 +122,45 @@ async def test_select_invalid_data( assert state.state == STATE_UNKNOWN state = hass.states.get("select.test_steering_wheel_heater") assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SELECT]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SEAT_HEATER_LEFT: 0, + Signal.SEAT_HEATER_RIGHT: 1, + Signal.SEAT_HEATER_REAR_LEFT: 2, + Signal.SEAT_HEATER_REAR_RIGHT: 3, + Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: 0, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.SELECT]) + + # Assert the entities restored their values + for entity_id in ( + "select.test_seat_heater_front_left", + "select.test_seat_heater_front_right", + "select.test_seat_heater_rear_left", + "select.test_seat_heater_rear_center", + "select.test_seat_heater_rear_right", + "select.test_steering_wheel_heater", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) From 9abea5c5bb419d1b5015957d4df568dd65138833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 5 Feb 2025 16:57:10 +0100 Subject: [PATCH 74/94] Add translation key for Matter Energy management mode (#137259) --- homeassistant/components/matter/select.py | 2 +- homeassistant/components/matter/strings.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index dd4f8314bef..b2d1c7f8ddb 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterDeviceEnergyManagementMode", - translation_key="mode", + translation_key="device_energy_management_mode", ), entity_class=MatterModeSelectEntity, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f1a123c61be..f299b5cb628 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -183,6 +183,9 @@ "mode": { "name": "Mode" }, + "device_energy_management_mode": { + "name": "Energy management mode" + }, "sensitivity_level": { "name": "Sensitivity", "state": { From 3f2e6d102cc0082fd979a40e2512dc7d5526ca57 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 16:58:39 +0100 Subject: [PATCH 75/94] Bump aiohasupervisor to version 0.3.0 (#137437) --- homeassistant/components/hassio/backup.py | 37 ++--- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_backup.py | 134 ++++++------------ 8 files changed, 71 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 142c5fc01ce..ddaa821587f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -20,6 +20,7 @@ from aiohasupervisor.models import ( backups as supervisor_backups, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from homeassistant.components.backup import ( DATA_MANAGER, @@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client -LOCATION_CLOUD_BACKUP = ".cloud_backup" -LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" # Set on backups automatically created when updating an addon @@ -72,7 +71,9 @@ async def async_get_backup_agents( """Return the hassio backup agents.""" client = get_supervisor_client(hass) mounts = await client.mounts.info() - agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + agents: list[BackupAgent] = [ + SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE) + ] for mount in mounts.mounts: if mount.usage is not supervisor_mounts.MountUsage.BACKUP: continue @@ -112,7 +113,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, location: str | None + details: supervisor_backups.BackupComplete, location: str ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -125,7 +126,6 @@ def _backup_details_to_agent_backup( for addon in details.addons ] extra_metadata = details.extra or {} - location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + def __init__(self, hass: HomeAssistant, name: str, location: str) -> None: """Initialize the backup agent.""" super().__init__() self._hass = hass @@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent): backup_list = await self._client.backups.list() result = [] for backup in backup_list: - if not backup.locations or self.location not in backup.locations: + if self.location not in backup.location_attributes: continue details = await self._client.backups.backup_info(backup.slug) result.append(_backup_details_to_agent_backup(details, self.location)) @@ -222,7 +222,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) except SupervisorNotFoundError: return None - if self.location not in details.locations: + if self.location not in details.location_attributes: return None return _backup_details_to_agent_backup(details, self.location) @@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): # will be handled by async_upload_backup. # If the lists are the same length, it does not matter which one we send, # we send the encrypted list to have a well defined behavior. - encrypted_locations: list[str | None] = [] - decrypted_locations: list[str | None] = [] + encrypted_locations: list[str] = [] + decrypted_locations: list[str] = [] agents_settings = manager.config.data.agents for hassio_agent in hassio_agents: if password is not None: @@ -353,12 +353,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): eager_start=False, # To ensure the task is not started before we return ) - return (NewBackup(backup_job_id=backup.job_id), backup_task) + return (NewBackup(backup_job_id=backup.job_id.hex), backup_task) async def _async_wait_for_backup( self, backup: supervisor_backups.NewBackup, - locations: list[str | None], + locations: list[str], *, on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, @@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): else None ) - restore_location: str | None + restore_location: str if manager.backup_agents[agent_id].domain != DOMAIN: # Download the backup to the supervisor. Supervisor will clean up the backup # two days after the restore is done. @@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" - if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)): _LOGGER.debug("No restore job ID found in environment") return + restore_job_id = UUID(restore_job_str) _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) sent_event = False @@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def _async_listen_job_events( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> Callable[[], None]: """Listen for job events.""" @@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): if ( data.get("event") != "job" or not (event_data := data.get("data")) - or event_data.get("uuid") != job_id + or event_data.get("uuid") != job_id.hex ): return on_event(event_data) @@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return unsub async def _get_job_state( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> None: """Poll a job for its state.""" - job = await self._client.jobs.get_job(UUID(job_id)) + job = await self._client.jobs.get_job(job_id) _LOGGER.debug("Job state: %s", job) on_event(job.to_dict()) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ccc0f23fb43..ad98beb5baa 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b6"], + "requirements": ["aiohasupervisor==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf9b7262194..df9ba88f1a2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.2.2 aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index f1baf85cdf3..8ddf46d8be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b6", + "aiohasupervisor==0.3.0", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", diff --git a/requirements.txt b/requirements.txt index 1a80837e2cc..d8d7b235390 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 1f0c440e76b..d3de85fdf58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.home_connect aiohomeconnect==0.12.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef4ac360d50..ba70aa2eec3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.home_connect aiohomeconnect==0.12.3 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6b6163acb15..0dd2adc99ed 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -26,6 +26,7 @@ from aiohasupervisor.models import ( jobs as supervisor_jobs, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,11 +40,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import ( - LOCATION_CLOUD_BACKUP, - LOCATION_LOCAL, - RESTORE_JOB_ID_ENV, -) +from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,17 +57,12 @@ TEST_BACKUP = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -89,14 +81,9 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP.location, location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, slug=TEST_BACKUP.slug, supervisor_version="2024.11.2", type=TEST_BACKUP.type, @@ -110,17 +97,12 @@ TEST_BACKUP_2 = supervisor_backups.Backup( homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -139,14 +121,9 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_2.location, location_attributes=TEST_BACKUP_2.location_attributes, - locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, - protected=TEST_BACKUP_2.protected, repositories=[], - size=TEST_BACKUP_2.size, - size_bytes=TEST_BACKUP_2.size_bytes, slug=TEST_BACKUP_2.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_2.type, @@ -160,17 +137,12 @@ TEST_BACKUP_3 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location="share", location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={"share"}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -189,14 +161,9 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_3.location, location_attributes=TEST_BACKUP_3.location_attributes, - locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, - protected=TEST_BACKUP_3.protected, repositories=[], - size=TEST_BACKUP_3.size, - size_bytes=TEST_BACKUP_3.size_bytes, slug=TEST_BACKUP_3.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_3.type, @@ -211,17 +178,12 @@ TEST_BACKUP_4 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -240,14 +202,9 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP_4.location, location_attributes=TEST_BACKUP_4.location_attributes, - locations=TEST_BACKUP_4.locations, name=TEST_BACKUP_4.name, - protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP_4.size, - size_bytes=TEST_BACKUP_4.size_bytes, slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_4.type, @@ -261,17 +218,12 @@ TEST_BACKUP_5 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=LOCATION_CLOUD_BACKUP, location_attributes={ LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={LOCATION_CLOUD_BACKUP}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -290,14 +242,9 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP_5.location, location_attributes=TEST_BACKUP_5.location_attributes, - locations=TEST_BACKUP_5.locations, name=TEST_BACKUP_5.name, - protected=TEST_BACKUP_5.protected, repositories=[], - size=TEST_BACKUP_5.size, - size_bytes=TEST_BACKUP_5.size_bytes, slug=TEST_BACKUP_5.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_5.type, @@ -312,6 +259,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=False, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_JOB_DONE = supervisor_jobs.Job( @@ -322,6 +270,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=True, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( @@ -340,6 +289,7 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( ), ) ], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) @@ -580,7 +530,10 @@ async def test_agent_download( assert await resp.content.read() == b"backup data" supervisor_client.backups.download_backup.assert_called_once_with( - "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + "abc123", + options=supervisor_backups.DownloadBackupOptions( + location=LOCATION_LOCAL_STORAGE + ), ) @@ -766,7 +719,10 @@ async def test_agent_delete_backup( assert response["success"] assert response["result"] == {"agent_errors": {}} supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -812,7 +768,10 @@ async def test_agent_delete_with_error( assert response == {"id": 1, "type": "result"} | expected_response supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -891,7 +850,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, - location=[None], + location=[LOCATION_LOCAL_STORAGE], name="Test", password=None, ) @@ -947,7 +906,7 @@ async def test_reader_writer_create( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1022,7 +981,7 @@ async def test_reader_writer_create_report_progress( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1129,7 +1088,7 @@ async def test_reader_writer_create_job_done( """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1198,7 +1157,7 @@ async def test_reader_writer_create_job_done( None, ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], False, [], ), @@ -1207,7 +1166,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], "hunter2", - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], True, [], ), @@ -1225,7 +1184,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share1", "share2", "share3"], True, - [None], + [LOCATION_LOCAL_STORAGE], ), ( [ @@ -1242,7 +1201,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share2", "share3"], True, - [None, "share1"], + [LOCATION_LOCAL_STORAGE, "share1"], ), ( [ @@ -1258,7 +1217,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2"], + [LOCATION_LOCAL_STORAGE, "share1", "share2"], True, ["share3"], ), @@ -1274,7 +1233,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local"], None, - [None], + [LOCATION_LOCAL_STORAGE], False, [], ), @@ -1312,15 +1271,14 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, extra=DEFAULT_BACKUP_OPTIONS.extra, - locations=create_locations, location_attributes={ - location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + location: supervisor_backups.BackupLocationAttributes( protected=create_protected, - size_bytes=TEST_BACKUP_DETAILS.size_bytes, + size_bytes=1048576, ) for location in create_locations }, @@ -1514,7 +1472,7 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1581,7 +1539,7 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) @@ -1668,7 +1626,7 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1745,7 +1703,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1848,7 +1806,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1975,7 +1933,7 @@ async def test_reader_writer_restore( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = get_job_result @@ -2006,7 +1964,7 @@ async def test_reader_writer_restore( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2040,7 +1998,7 @@ async def test_reader_writer_restore_report_progress( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2071,7 +2029,7 @@ async def test_reader_writer_restore_report_progress( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2193,7 +2151,7 @@ async def test_reader_writer_restore_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2221,7 +2179,7 @@ async def test_reader_writer_restore_late_error( ) -> None: """Test restoring a backup with error.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2250,7 +2208,7 @@ async def test_reader_writer_restore_late_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) From 078996effd63b4a51a89e8e00e96c4ea458397f4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Feb 2025 17:07:19 +0100 Subject: [PATCH 76/94] Update frontend to 20250205.0 (#137441) --- 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 b584fe5e2f0..d27785dcea5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250204.0"] + "requirements": ["home-assistant-frontend==20250205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index df9ba88f1a2..ecfee7cee04 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 hassil==2.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d3de85fdf58..48fe3b24a16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba70aa2eec3..400a1522bb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 4694240cfa49dbe6b056ee84c71cbd978cd7e48d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 5 Feb 2025 09:07:35 -0700 Subject: [PATCH 77/94] Refactor switch for vesync (#134409) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/common.py | 14 +++ homeassistant/components/vesync/switch.py | 115 +++++++++++------- .../vesync/snapshots/test_switch.ambr | 6 +- 3 files changed, 86 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index e2f4e1db2e4..f817c1d0714 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -4,6 +4,8 @@ import logging from pyvesync import VeSync from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant @@ -54,3 +56,15 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: """Check if the device represents a humidifier.""" return isinstance(device, VeSyncHumidifierDevice) + + +def is_outlet(device: VeSyncBaseDevice) -> bool: + """Check if the device represents an outlet.""" + + return isinstance(device, VeSyncOutlet) + + +def is_wall_switch(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + + return isinstance(device, VeSyncWallSwitch) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index efae1192406..3d2dc8a8e96 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,29 +1,59 @@ """Support for VeSync switches.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Final from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) 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 DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .common import is_outlet, is_wall_switch +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class VeSyncSwitchEntityDescription(SwitchEntityDescription): + """A class that describes custom switch entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] + on_fn: Callable[[VeSyncBaseDevice], bool] + off_fn: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( + VeSyncSwitchEntityDescription( + key="device_status", + is_on=lambda device: device.device_status == "on", + # Other types of wall switches support dimming. Those use light.py platform. + exists_fn=lambda device: is_wall_switch(device) or is_outlet(device), + name=None, + on_fn=lambda device: device.turn_on(), + off_fn=lambda device: device.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up switch platform.""" coordinator = hass.data[DOMAIN][VS_COORDINATOR] @@ -45,55 +75,46 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is a switch and add entity.""" - entities: list[VeSyncBaseSwitch] = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev, coordinator)) - elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev, coordinator)) - - async_add_entities(entities, update_before_add=True) + """Check if device is online and add entity.""" + async_add_entities( + VeSyncSwitchEntity(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if description.exists_fn(dev) + ) -class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): - """Base class for VeSync switch Device Representations.""" +class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): + """VeSync switch entity class.""" - _attr_name = None + entity_description: VeSyncSwitchEntityDescription - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self.device.turn_on() + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSwitchEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + if is_outlet(self.device): + self._attr_device_class = SwitchDeviceClass.OUTLET + elif is_wall_switch(self.device): + self._attr_device_class = SwitchDeviceClass.SWITCH @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" + def is_on(self) -> bool | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.is_on(self.device) def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() + """Turn the entity off.""" + if self.entity_description.off_fn(self.device): + self.schedule_update_ha_state() - -class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): - """Representation of a VeSync switch.""" - - def __init__( - self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync switch device.""" - super().__init__(plug, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.smartplug = plug - - -class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): - """Handle representation of VeSync Light Switch.""" - - def __init__( - self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize Light Switch device class.""" - super().__init__(switch, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.switch = switch + def turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if self.entity_description.on_fn(self.device): + self.schedule_update_ha_state() diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index da652b30ac5..a736f1cd186 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -360,7 +360,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -375,6 +375,7 @@ # name: test_switch_state[Outlet][switch.outlet] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', 'friendly_name': 'Outlet', }), 'context': , @@ -518,7 +519,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -533,6 +534,7 @@ # name: test_switch_state[Wall Switch][switch.wall_switch] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'switch', 'friendly_name': 'Wall Switch', }), 'context': , From 830636df07e190a8d00162a9de0eacfe780dc68a Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:18:32 +0100 Subject: [PATCH 78/94] Govee light local bump (#137436) --- .../components/govee_light_local/light.py | 8 ++++---- .../components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/govee_light_local/conftest.py | 11 +++++------ .../govee_light_local/test_config_flow.py | 4 ++-- tests/components/govee_light_local/test_light.py | 16 ++++++++-------- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index cb2e24fa8a6..c7799a7ffc4 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from govee_local_api import GoveeDevice, GoveeLightCapability +from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -71,13 +71,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): capabilities = device.capabilities color_modes = {ColorMode.ONOFF} if capabilities: - if GoveeLightCapability.COLOR_RGB in capabilities: + if GoveeLightFeatures.COLOR_RGB & capabilities.features: color_modes.add(ColorMode.RGB) - if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + if GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE & capabilities.features: color_modes.add(ColorMode.COLOR_TEMP) self._attr_max_color_temp_kelvin = 9000 self._attr_min_color_temp_kelvin = 2000 - if GoveeLightCapability.BRIGHTNESS in capabilities: + if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(color_modes) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index a94d4e58e9a..e813ab545df 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.5.3"] + "requirements": ["govee-local-api==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48fe3b24a16..608ba7b79f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1055,7 +1055,7 @@ gotailwind==0.3.0 govee-ble==0.42.1 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 400a1522bb8..a0bf2263de4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -905,7 +905,7 @@ gotailwind==0.3.0 govee-ble==0.42.1 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.0 # homeassistant.components.gpsd gps3==0.33.3 diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 6a8ee99b764..61a6394bd6a 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,7 +4,8 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapability +from govee_local_api import GoveeLightCapabilities +from govee_local_api.light_capabilities import COMMON_FEATURES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @@ -34,8 +35,6 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { - GoveeLightCapability.COLOR_RGB, - GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, - GoveeLightCapability.BRIGHTNESS, -} +DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES, segments=[], scenes={} +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 2e7144fae3a..103159f1a2b 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: @@ -20,7 +20,7 @@ def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 4a1125643fa..24bdbba9e11 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES from tests.common import MockConfigEntry @@ -26,7 +26,7 @@ async def test_light_known_device( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -96,7 +96,7 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -152,7 +152,7 @@ async def test_light_setup_retry_eaddrinuse( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -180,7 +180,7 @@ async def test_light_setup_error( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -204,7 +204,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -260,7 +260,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -335,7 +335,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] From c9ab75a02d465f80fad6054b26d26244ef8d78dd Mon Sep 17 00:00:00 2001 From: Arkadiusz Wahlig Date: Wed, 5 Feb 2025 17:38:34 +0100 Subject: [PATCH 79/94] Add support for Switchbot Remote (#137443) --- .../components/switchbot/__init__.py | 1 + homeassistant/components/switchbot/const.py | 2 + tests/components/switchbot/__init__.py | 20 ++++++++++ tests/components/switchbot/test_sensor.py | 40 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 499a5073872..09bc157d4d2 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -65,6 +65,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.REMOTE.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 854ab32b657..16b41d75541 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -34,6 +34,7 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1PM = "relay_switch_1pm" RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" + REMOTE = "remote" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -60,6 +61,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, + SwitchbotModel.REMOTE: SupportedModels.REMOTE, } SUPPORTED_MODEL_TYPES = ( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9ecffd395a3..4d6794b962f 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -274,3 +274,23 @@ LEAK_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + +REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Any"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index acf1bacc054..6a7111a054e 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from . import ( LEAK_SERVICE_INFO, + REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, @@ -194,3 +195,42 @@ async def test_leak_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remote(hass: HomeAssistant) -> None: + """Test setting up the remote sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, REMOTE_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "remote", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "86" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From a1c675b5eed1785442e7c09cff3b8f2d5fa1a3c8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Feb 2025 11:02:01 -0600 Subject: [PATCH 80/94] Bump hassil and intents (#137440) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0485cb75fcb..2d4a8053d75 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ecfee7cee04..ad74191a781 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,10 +35,10 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 hass-nabucasa==0.89.0 -hassil==2.2.0 +hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250205.0 -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 608ba7b79f5..b8afcd96fe2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ hass-nabucasa==0.89.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1149,7 +1149,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0bf2263de4..c3d47788d00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -978,7 +978,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 22eae847706..5598c839257 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 164f43a71bdef4275812f78fd3a11730c58efadd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 11:17:02 -0600 Subject: [PATCH 81/94] Bump dbus-fast to 2.33.0 (#137446) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.32.0...v2.33.0 --- 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 a0405eb5ef5..5d2b8ab6285 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.4", - "dbus-fast==2.32.0", + "dbus-fast==2.33.0", "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ad74191a781..46bdc2b9f68 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.32.0 +dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index b8afcd96fe2..f637bf8e5bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3d47788d00..f95171794aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,7 +634,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 From 09483d2cef3e90348cde39564a1263d7f86f5ec0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:27:13 +0100 Subject: [PATCH 82/94] Move fireservicerota coordinator to own module (#137304) --- .../components/fireservicerota/__init__.py | 193 +--------------- .../fireservicerota/binary_sensor.py | 15 +- .../components/fireservicerota/coordinator.py | 213 ++++++++++++++++++ .../fireservicerota/test_config_flow.py | 2 +- 4 files changed, 225 insertions(+), 198 deletions(-) create mode 100644 homeassistant/components/fireservicerota/coordinator.py diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index aa303a08795..360a0f0b210 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -3,29 +3,16 @@ from __future__ import annotations from datetime import timedelta -import logging - -from pyfireservicerota import ( - ExpiredTokenError, - FireServiceRota, - FireServiceRotaIncidents, - InvalidAuthError, - InvalidTokenError, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN, WSS_BWRURL +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -40,17 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if client.token_refresh_failure: return False - async def async_update_data(): - return await client.async_update() - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="duty binary sensor", - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = FireServiceUpdateCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -74,165 +51,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class FireServiceRotaOauth: - """Handle authentication tokens.""" - - def __init__(self, hass, entry, fsr): - """Initialize the oauth object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._username = entry.data[CONF_USERNAME] - self._fsr = fsr - - async def async_refresh_tokens(self) -> bool: - """Refresh tokens and update config entry.""" - _LOGGER.debug("Refreshing authentication tokens after expiration") - - try: - token_info = await self._hass.async_add_executor_job( - self._fsr.refresh_tokens - ) - - except (InvalidAuthError, InvalidTokenError) as err: - raise ConfigEntryAuthFailed( - "Error refreshing tokens, triggered reauth workflow" - ) from err - - _LOGGER.debug("Saving new tokens in config entry") - self._hass.config_entries.async_update_entry( - self._entry, - data={ - "auth_implementation": DOMAIN, - CONF_URL: self._url, - CONF_USERNAME: self._username, - CONF_TOKEN: token_info, - }, - ) - - return True - - -class FireServiceRotaWebSocket: - """Define a FireServiceRota websocket manager object.""" - - def __init__(self, hass, entry): - """Initialize the websocket object.""" - self._hass = hass - self._entry = entry - - self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) - self.incident_data = None - - def _construct_url(self) -> str: - """Return URL with latest access token.""" - return WSS_BWRURL.format( - self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] - ) - - def _on_incident(self, data) -> None: - """Received new incident, update data.""" - _LOGGER.debug("Received new incident via websocket: %s", data) - self.incident_data = data - dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") - - def start_listener(self) -> None: - """Start the websocket listener.""" - _LOGGER.debug("Starting incidents listener") - self._fsr_incidents.start(self._construct_url()) - - def stop_listener(self) -> None: - """Stop the websocket listener.""" - _LOGGER.debug("Stopping incidents listener") - self._fsr_incidents.stop() - - -class FireServiceRotaClient: - """Getting the latest data from fireservicerota.""" - - def __init__(self, hass, entry): - """Initialize the data object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._tokens = entry.data[CONF_TOKEN] - - self.entry_id = entry.entry_id - self.unique_id = entry.unique_id - - self.token_refresh_failure = False - self.incident_id = None - self.on_duty = False - - self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) - - self.oauth = FireServiceRotaOauth( - self._hass, - self._entry, - self.fsr, - ) - - self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) - - async def setup(self) -> None: - """Set up the data client.""" - await self._hass.async_add_executor_job(self.websocket.start_listener) - - async def update_call(self, func, *args): - """Perform update call and return data.""" - if self.token_refresh_failure: - return None - - try: - return await self._hass.async_add_executor_job(func, *args) - except (ExpiredTokenError, InvalidTokenError): - await self._hass.async_add_executor_job(self.websocket.stop_listener) - self.token_refresh_failure = True - - if await self.oauth.async_refresh_tokens(): - self.token_refresh_failure = False - await self._hass.async_add_executor_job(self.websocket.start_listener) - - return await self._hass.async_add_executor_job(func, *args) - - async def async_update(self) -> dict | None: - """Get the latest availability data.""" - data = await self.update_call( - self.fsr.get_availability, str(self._hass.config.time_zone) - ) - - if not data: - return None - - self.on_duty = bool(data.get("available")) - - _LOGGER.debug("Updated availability data: %s", data) - return data - - async def async_response_update(self) -> dict | None: - """Get the latest incident response data.""" - - if not self.incident_id: - return None - - _LOGGER.debug("Updating response data for incident id %s", self.incident_id) - - return await self.update_call(self.fsr.get_incident_response, self.incident_id) - - async def async_set_response(self, value) -> None: - """Set incident response status.""" - - if not self.incident_id: - return - - _LOGGER.debug( - "Setting incident response for incident id '%s' to state '%s'", - self.incident_id, - value, - ) - - await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index a22991f2008..b6d3aa67a0a 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -8,13 +8,10 @@ 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.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FireServiceRotaClient from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator async def async_setup_entry( @@ -26,14 +23,16 @@ async def async_setup_entry( DATA_CLIENT ] - coordinator: DataUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ + coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ entry.entry_id ][DATA_COORDINATOR] async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) -class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): +class ResponseBinarySensor( + CoordinatorEntity[FireServiceUpdateCoordinator], BinarySensorEntity +): """Representation of an FireServiceRota sensor.""" _attr_has_entity_name = True @@ -41,7 +40,7 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: FireServiceUpdateCoordinator, client: FireServiceRotaClient, entry: ConfigEntry, ) -> None: diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py new file mode 100644 index 00000000000..35f839b3bdb --- /dev/null +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -0,0 +1,213 @@ +"""The FireServiceRota integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyfireservicerota import ( + ExpiredTokenError, + FireServiceRota, + FireServiceRotaIncidents, + InvalidAuthError, + InvalidTokenError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, WSS_BWRURL + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + + +class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): + """Data update coordinator for FireServiceRota.""" + + def __init__( + self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry + ) -> None: + """Initialize the FireServiceRota DataUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="duty binary sensor", + config_entry=entry, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + self.client = client + + async def _async_update_data(self) -> dict | None: + """Get the latest availability data.""" + return await self.client.async_update() + + +class FireServiceRotaOauth: + """Handle authentication tokens.""" + + def __init__(self, hass, entry, fsr): + """Initialize the oauth object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._username = entry.data[CONF_USERNAME] + self._fsr = fsr + + async def async_refresh_tokens(self) -> bool: + """Refresh tokens and update config entry.""" + _LOGGER.debug("Refreshing authentication tokens after expiration") + + try: + token_info = await self._hass.async_add_executor_job( + self._fsr.refresh_tokens + ) + + except (InvalidAuthError, InvalidTokenError) as err: + raise ConfigEntryAuthFailed( + "Error refreshing tokens, triggered reauth workflow" + ) from err + + _LOGGER.debug("Saving new tokens in config entry") + self._hass.config_entries.async_update_entry( + self._entry, + data={ + "auth_implementation": DOMAIN, + CONF_URL: self._url, + CONF_USERNAME: self._username, + CONF_TOKEN: token_info, + }, + ) + + return True + + +class FireServiceRotaWebSocket: + """Define a FireServiceRota websocket manager object.""" + + def __init__(self, hass, entry): + """Initialize the websocket object.""" + self._hass = hass + self._entry = entry + + self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) + self.incident_data = None + + def _construct_url(self) -> str: + """Return URL with latest access token.""" + return WSS_BWRURL.format( + self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] + ) + + def _on_incident(self, data) -> None: + """Received new incident, update data.""" + _LOGGER.debug("Received new incident via websocket: %s", data) + self.incident_data = data + dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") + + def start_listener(self) -> None: + """Start the websocket listener.""" + _LOGGER.debug("Starting incidents listener") + self._fsr_incidents.start(self._construct_url()) + + def stop_listener(self) -> None: + """Stop the websocket listener.""" + _LOGGER.debug("Stopping incidents listener") + self._fsr_incidents.stop() + + +class FireServiceRotaClient: + """Getting the latest data from fireservicerota.""" + + def __init__(self, hass, entry): + """Initialize the data object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._tokens = entry.data[CONF_TOKEN] + + self.entry_id = entry.entry_id + self.unique_id = entry.unique_id + + self.token_refresh_failure = False + self.incident_id = None + self.on_duty = False + + self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) + + self.oauth = FireServiceRotaOauth( + self._hass, + self._entry, + self.fsr, + ) + + self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) + + async def setup(self) -> None: + """Set up the data client.""" + await self._hass.async_add_executor_job(self.websocket.start_listener) + + async def update_call(self, func, *args): + """Perform update call and return data.""" + if self.token_refresh_failure: + return None + + try: + return await self._hass.async_add_executor_job(func, *args) + except (ExpiredTokenError, InvalidTokenError): + await self._hass.async_add_executor_job(self.websocket.stop_listener) + self.token_refresh_failure = True + + if await self.oauth.async_refresh_tokens(): + self.token_refresh_failure = False + await self._hass.async_add_executor_job(self.websocket.start_listener) + + return await self._hass.async_add_executor_job(func, *args) + + async def async_update(self) -> dict | None: + """Get the latest availability data.""" + data = await self.update_call( + self.fsr.get_availability, str(self._hass.config.time_zone) + ) + + if not data: + return None + + self.on_duty = bool(data.get("available")) + + _LOGGER.debug("Updated availability data: %s", data) + return data + + async def async_response_update(self) -> dict | None: + """Get the latest incident response data.""" + + if not self.incident_id: + return None + + _LOGGER.debug("Updating response data for incident id %s", self.incident_id) + + return await self.update_call(self.fsr.get_incident_response, self.incident_id) + + async def async_set_response(self, value) -> None: + """Set incident response status.""" + + if not self.incident_id: + return + + _LOGGER.debug( + "Setting incident response for incident id '%s' to state '%s'", + self.incident_id, + value, + ) + + await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 5555a8d649c..8d150034ec9 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -66,7 +66,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test that invalid credentials throws an error.""" with patch( - "homeassistant.components.fireservicerota.FireServiceRota.request_tokens", + "homeassistant.components.fireservicerota.coordinator.FireServiceRota.request_tokens", side_effect=InvalidAuthError, ): result = await hass.config_entries.flow.async_init( From 15bc29f8cac18ed22583b7c1a749050dfbc3f74c Mon Sep 17 00:00:00 2001 From: Jan Rieger <271149+jrieger@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:34:43 +0100 Subject: [PATCH 83/94] Add GPSd satellites sensors (#137320) --- homeassistant/components/gpsd/icons.json | 6 ++++ homeassistant/components/gpsd/sensor.py | 36 ++++++++++++++++++++++ homeassistant/components/gpsd/strings.json | 8 +++++ 3 files changed, 50 insertions(+) diff --git a/homeassistant/components/gpsd/icons.json b/homeassistant/components/gpsd/icons.json index 59d904f918c..3605bdc6d70 100644 --- a/homeassistant/components/gpsd/icons.json +++ b/homeassistant/components/gpsd/icons.json @@ -16,6 +16,12 @@ }, "elevation": { "default": "mdi:arrow-up-down" + }, + "total_satellites": { + "default": "mdi:satellite-variant" + }, + "used_satellites": { + "default": "mdi:satellite-variant" } } } diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 1bac41ecaae..70d32f88a65 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_LATITUDE, @@ -39,12 +40,31 @@ ATTR_CLIMB = "climb" ATTR_ELEVATION = "elevation" ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" +ATTR_TOTAL_SATELLITES = "total_satellites" +ATTR_USED_SATELLITES = "used_satellites" DEFAULT_NAME = "GPS" _MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} +def count_total_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of total satellites.""" + satellites = agps_thread.data_stream.satellites + return None if satellites == "n/a" else len(satellites) + + +def count_used_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of used satellites.""" + satellites = agps_thread.data_stream.satellites + if satellites == "n/a": + return None + + return sum( + 1 for sat in satellites if isinstance(sat, dict) and sat.get("used", False) + ) + + @dataclass(frozen=True, kw_only=True) class GpsdSensorDescription(SensorEntityDescription): """Class describing GPSD sensor entities.""" @@ -116,6 +136,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = ( suggested_display_precision=2, entity_registry_enabled_default=False, ), + GpsdSensorDescription( + key=ATTR_TOTAL_SATELLITES, + translation_key=ATTR_TOTAL_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_total_satellites_fn, + entity_registry_enabled_default=False, + ), + GpsdSensorDescription( + key=ATTR_USED_SATELLITES, + translation_key=ATTR_USED_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_used_satellites_fn, + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json index 867edf0b5a8..a5d6c570b54 100644 --- a/homeassistant/components/gpsd/strings.json +++ b/homeassistant/components/gpsd/strings.json @@ -50,6 +50,14 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]" } } + }, + "total_satellites": { + "name": "Total satellites", + "unit_of_measurement": "satellites" + }, + "used_satellites": { + "name": "Used satellites", + "unit_of_measurement": "satellites" } } } From 94614e0376bfa301841f1350401ec7b9e701ef63 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 6 Feb 2025 08:01:45 +1300 Subject: [PATCH 84/94] Change Electric Kiwi authentication (#135231) Co-authored-by: Joostlek --- .../components/electric_kiwi/__init__.py | 64 +++++- homeassistant/components/electric_kiwi/api.py | 26 ++- .../components/electric_kiwi/config_flow.py | 37 +++- .../components/electric_kiwi/const.py | 2 +- .../components/electric_kiwi/coordinator.py | 18 +- .../components/electric_kiwi/manifest.json | 2 +- .../components/electric_kiwi/select.py | 4 +- .../components/electric_kiwi/sensor.py | 24 ++- .../components/electric_kiwi/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/electric_kiwi/__init__.py | 12 ++ tests/components/electric_kiwi/conftest.py | 162 +++++++++----- .../fixtures/account_balance.json | 28 --- .../fixtures/account_summary.json | 43 ++++ .../fixtures/connection_details.json | 73 +++++++ .../electric_kiwi/fixtures/get_hop.json | 20 +- .../electric_kiwi/fixtures/hop_intervals.json | 199 +++++++++--------- .../electric_kiwi/fixtures/session.json | 23 ++ .../fixtures/session_no_services.json | 16 ++ .../electric_kiwi/test_config_flow.py | 127 ++++++----- tests/components/electric_kiwi/test_init.py | 135 ++++++++++++ tests/components/electric_kiwi/test_sensor.py | 27 ++- 23 files changed, 753 insertions(+), 296 deletions(-) delete mode 100644 tests/components/electric_kiwi/fixtures/account_balance.json create mode 100644 tests/components/electric_kiwi/fixtures/account_summary.json create mode 100644 tests/components/electric_kiwi/fixtures/connection_details.json create mode 100644 tests/components/electric_kiwi/fixtures/session.json create mode 100644 tests/components/electric_kiwi/fixtures/session_no_services.json create mode 100644 tests/components/electric_kiwi/test_init.py diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index de8d87553a3..825dbc54013 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -4,12 +4,16 @@ from __future__ import annotations import aiohttp from electrickiwi_api import ElectricKiwiApi -from electrickiwi_api.exceptions import ApiException +from electrickiwi_api.exceptions import ApiException, AuthException from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + entity_registry as er, +) from . import api from .coordinator import ( @@ -44,7 +48,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err ek_api = ElectricKiwiApi( - api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) @@ -53,6 +59,8 @@ async def async_setup_entry( await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh() + except AuthException as err: + raise ConfigEntryAuthFailed from err except ApiException as err: raise ConfigEntryNotReady from err @@ -70,3 +78,53 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version == 1 and config_entry.minor_version == 1: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + + ek_api = ElectricKiwiApi( + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + ) + try: + await ek_api.set_active_session() + connection_details = await ek_api.get_connection_details() + except AuthException: + config_entry.async_start_reauth(hass) + return False + except ApiException: + return False + unique_id = str(ek_api.customer_number) + identifier = ek_api.electricity.identifier + hass.config_entries.async_update_entry( + config_entry, unique_id=unique_id, minor_version=2 + ) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + + for entity in entity_entries: + assert entity.config_entry_id + entity_registry.async_update_entity( + entity.entity_id, + new_unique_id=entity.unique_id.replace( + f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}" + ), + ) + + return True diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py index dead8a6a3c0..9f7ff333378 100644 --- a/homeassistant/components/electric_kiwi/api.py +++ b/homeassistant/components/electric_kiwi/api.py @@ -2,17 +2,16 @@ from __future__ import annotations -from typing import cast - from aiohttp import ClientSession from electrickiwi_api import AbstractAuth -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import API_BASE_URL -class AsyncConfigEntryAuth(AbstractAuth): +class ConfigEntryElectricKiwiAuth(AbstractAuth): """Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth): """Return a valid access token.""" await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token["access_token"]) + return str(self._oauth_session.token["access_token"]) + + +class ConfigFlowElectricKiwiAuth(AbstractAuth): + """Provide Electric Kiwi authentication tied to an OAuth2 based config flow.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Electric Kiwi API.""" + return self._token diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py index b74ab4268e2..b83fd89c4c6 100644 --- a/homeassistant/components/electric_kiwi/config_flow.py +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -6,9 +6,14 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import ConfigFlowResult +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_entry_oauth2_flow +from . import api from .const import DOMAIN, SCOPE_VALUES @@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler( ): """Config flow to handle Electric Kiwi OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler( ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) return await self.async_step_user() async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for Electric Kiwi.""" - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: - return self.async_update_reload_and_abort(existing_entry, data=data) - return await super().async_oauth_create_entry(data) + ek_api = ElectricKiwiApi( + api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"]) + ) + + try: + session = await ek_api.get_active_session() + except ApiException: + return self.async_abort(reason="connection_error") + + unique_id = str(session.data.customer_number) + await self.async_set_unique_id(unique_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=unique_id, data=data) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..c51422a7c72 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" -SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" +SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 2065da5d668..635b55b2bc0 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -10,7 +10,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import AccountSummary, Hop, HopIntervals from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData: type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] -class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]): """ElectricKiwi Account Data object.""" def __init__( @@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api - async def _async_update_data(self) -> AccountBalance: + async def _async_update_data(self) -> AccountSummary: """Fetch data from Account balance API endpoint.""" try: async with asyncio.timeout(60): - return await self._ek_api.get_account_balance() + return await self.ek_api.get_account_summary() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): # Polling interval. Will only be polled if there are subscribers. update_interval=HOP_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api self.hop_intervals: HopIntervals | None = None def get_hop_options(self) -> dict[str, int]: @@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): async def async_update_hop(self, hop_interval: int) -> Hop: """Update selected hop and data.""" try: - self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) + self.async_set_updated_data(await self.ek_api.post_hop(hop_interval)) except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): try: async with asyncio.timeout(60): if self.hop_intervals is None: - hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() + hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( filter( lambda pair: pair[1].active == 1, @@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): ) self.hop_intervals = hop_intervals - return await self._ek_api.get_hop() + return await self.ek_api.get_hop() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 8ddb4c1af7c..9afe487d368 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.8.5"] + "requirements": ["electrickiwi-api==0.9.12"] } diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index fa111381612..30e02b5c5b9 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity( """Initialise the HOP selection entity.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description self.values_dict = coordinator.get_hop_options() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index e070f9495c1..410d70808c3 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from electrickiwi_api.model import AccountBalance, Hop +from electrickiwi_api.model import AccountSummary, Hop from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage" class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): """Describes Electric Kiwi sensor entity.""" - value_func: Callable[[AccountBalance], float | datetime] + value_func: Callable[[AccountSummary], float | datetime] + + +def _get_hop_percentage(account_balance: AccountSummary) -> float: + """Return the hop percentage from account summary.""" + if power := account_balance.services.get("power"): + if connection := power.connections[0]: + return float(connection.hop_percentage) + return 0.0 ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( @@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( translation_key="hop_power_savings", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_func=lambda account_balance: float( - account_balance.connections[0].hop_percentage - ), + value_func=_get_hop_percentage, ), ) @@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description @@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 410d32909ba..5e0a2ef168d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -21,7 +21,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/requirements_all.txt b/requirements_all.txt index f637bf8e5bd..284afccb5f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f95171794aa..07ddbd78fb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,7 +702,7 @@ easyenergy==2.1.2 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py index 7f5e08a56b5..936557ac3bf 100644 --- a/tests/components/electric_kiwi/__init__.py +++ b/tests/components/electric_kiwi/__init__.py @@ -1 +1,13 @@ """Tests for the Electric Kiwi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Fixture for setting up the integration with args.""" + 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/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 010efcb7b5f..cc967631be4 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from time import time from unittest.mock import AsyncMock, patch -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import ( + AccountSummary, + CustomerConnection, + Hop, + HopIntervals, + Service, + Session, +) import pytest from homeassistant.components.application_credentials import ( @@ -23,37 +30,55 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -type YieldFixture = Generator[AsyncMock] -type ComponentSetup = Callable[[], Awaitable[bool]] + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host: None) -> None: - """Request setup.""" - - -@pytest.fixture -def component_setup( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> ComponentSetup: - """Fixture for setting up the integration.""" - - async def _setup_func() -> bool: - assert await async_setup_component(hass, "application_credentials", {}) - await hass.async_block_till_done() - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, +def electrickiwi_api() -> Generator[AsyncMock]: + """Mock ek api and return values.""" + with ( + patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.customer_number = 123456 + client.electricity = Service( + identifier="00000000DDA", + service="electricity", + service_status="Y", + is_primary_service=True, ) - await hass.async_block_till_done() - config_entry.add_to_hass(hass) - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return _setup_func + client.get_active_session.return_value = Session.from_dict( + load_json_value_fixture("session.json", DOMAIN) + ) + client.get_hop_intervals.return_value = HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + client.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + client.get_account_summary.return_value = AccountSummary.from_dict( + load_json_value_fixture("account_summary.json", DOMAIN) + ) + client.get_connection_details.return_value = CustomerConnection.from_dict( + load_json_value_fixture("connection_details.json", DOMAIN) + ) + yield client @pytest.fixture(name="config_entry") @@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "12345", + "id": "123456", "auth_implementation": DOMAIN, "token": { "refresh_token": "mock-refresh-token", @@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, }, unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="config_entry2") +def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123457", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="1234567", + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="migrated_config_entry") +def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123456", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456", + version=1, + minor_version=2, ) @@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="ek_auth") -def electric_kiwi_auth() -> YieldFixture: +def electric_kiwi_auth() -> Generator[AsyncMock]: """Patch access to electric kiwi access token.""" with patch( - "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth" ) as mock_auth: mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") yield mock_auth - - -@pytest.fixture(name="ek_api") -def ek_api() -> YieldFixture: - """Mock ek api and return values.""" - with patch( - "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True - ) as mock_ek_api: - mock_ek_api.return_value.customer_number = 123456 - mock_ek_api.return_value.connection_id = 123456 - mock_ek_api.return_value.set_active_session.return_value = None - mock_ek_api.return_value.get_hop_intervals.return_value = ( - HopIntervals.from_dict( - load_json_value_fixture("hop_intervals.json", DOMAIN) - ) - ) - mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( - load_json_value_fixture("get_hop.json", DOMAIN) - ) - mock_ek_api.return_value.get_account_balance.return_value = ( - AccountBalance.from_dict( - load_json_value_fixture("account_balance.json", DOMAIN) - ) - ) - yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json deleted file mode 100644 index 25bc57784ee..00000000000 --- a/tests/components/electric_kiwi/fixtures/account_balance.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data": { - "connections": [ - { - "hop_percentage": "3.5", - "id": 3, - "running_balance": "184.09", - "start_date": "2020-10-04", - "unbilled_days": 15 - } - ], - "last_billed_amount": "-66.31", - "last_billed_date": "2020-10-03", - "next_billing_date": "2020-11-03", - "is_prepay": "N", - "summary": { - "credits": "0.0", - "electricity_used": "184.09", - "other_charges": "0.00", - "payments": "-220.0" - }, - "total_account_balance": "-102.22", - "total_billing_days": 30, - "total_running_balance": "184.09", - "type": "account_running_balance" - }, - "status": 1 -} diff --git a/tests/components/electric_kiwi/fixtures/account_summary.json b/tests/components/electric_kiwi/fixtures/account_summary.json new file mode 100644 index 00000000000..6a05d6a3fe7 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_summary.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "account_summary", + "total_running_balance": "184.09", + "total_account_balance": "-102.22", + "total_billing_days": 31, + "next_billing_date": "2025-02-19", + "service_names": ["power"], + "services": { + "power": { + "connections": [ + { + "id": 515363, + "running_balance": "12.98", + "unbilled_days": 5, + "hop_percentage": "11.2", + "start_date": "2025-01-19", + "service_label": "Power" + } + ] + } + }, + "date_to_pay": "", + "invoice_id": "", + "total_invoiced_charges": "", + "default_to_pay": "", + "invoice_exists": 1, + "display_date": "2025-01-19", + "last_billed_date": "2025-01-18", + "last_billed_amount": "-21.02", + "summary": { + "electricity_used": "12.98", + "other_charges": "0.00", + "payments": "0.00", + "credits": "0.00", + "mobile_charges": "0.00", + "broadband_charges": "0.00", + "addon_unbilled_charges": {} + }, + "is_prepay": "N" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/connection_details.json b/tests/components/electric_kiwi/fixtures/connection_details.json new file mode 100644 index 00000000000..5b446659aab --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/connection_details.json @@ -0,0 +1,73 @@ +{ + "data": { + "type": "connection", + "id": 515363, + "customer_id": 273941, + "customer_number": 34030646, + "icp_identifier": "00000000DDA", + "address": "", + "short_address": "", + "physical_address_unit": "", + "physical_address_number": "555", + "physical_address_street": "RACECOURSE ROAD", + "physical_address_suburb": "", + "physical_address_town": "Blah", + "physical_address_region": "Blah", + "physical_address_postcode": "0000", + "is_active": "Y", + "pricing_plan": { + "id": 51423, + "usage": "0.0000", + "fixed": "0.6000", + "usage_rate_inc_gst": "0.0000", + "supply_rate_inc_gst": "0.6900", + "plan_description": "MoveMaster Anytime Residential (Low User)", + "plan_type": "movemaster_tou", + "signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.", + "signup_price_plan_label": "MoveMaster", + "app_price_plan_label": "Your MoveMaster rates are...", + "solar_rate_excl_gst": "0.1250", + "solar_rate_incl_gst": "0.1438", + "pricing_type": "tou_plus", + "tou_plus": { + "fixed_rate_excl_gst": "0.6000", + "fixed_rate_incl_gst": "0.6900", + "interval_types": ["peak", "off_peak_shoulder", "off_peak_night"], + "peak": { + "price_excl_gst": "0.5390", + "price_incl_gst": "0.6199", + "display_text": { + "Weekdays": "7am-9am, 5pm-9pm" + }, + "tou_plus_label": "Peak" + }, + "off_peak_shoulder": { + "price_excl_gst": "0.3234", + "price_incl_gst": "0.3719", + "display_text": { + "Weekdays": "9am-5pm, 9pm-11pm", + "Weekends": "7am-11pm" + }, + "tou_plus_label": "Off-peak shoulder" + }, + "off_peak_night": { + "price_excl_gst": "0.2695", + "price_incl_gst": "0.3099", + "display_text": { + "Every day": "11pm-7am" + }, + "tou_plus_label": "Off-peak night" + } + } + }, + "hop": { + "start_time": "9:00 PM", + "end_time": "10:00 PM", + "interval_start": "43", + "interval_end": "44" + }, + "start_date": "2022-03-03", + "end_date": "", + "property_type": "residential" + } +} diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json index d29825391e9..2b126bfc017 100644 --- a/tests/components/electric_kiwi/fixtures/get_hop.json +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -1,16 +1,18 @@ { "data": { - "connection_id": "3", - "customer_number": 1000001, - "end": { - "end_time": "5:00 PM", - "interval": "34" - }, + "type": "hop_customer", + "customer_id": 123456, + "service_type": "electricity", + "connection_id": 515363, + "billing_id": 1247975, "start": { - "start_time": "4:00 PM", - "interval": "33" + "interval": "33", + "start_time": "4:00 PM" }, - "type": "hop_customer" + "end": { + "interval": "34", + "end_time": "5:00 PM" + } }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json index 15ecc174f13..860630b000a 100644 --- a/tests/components/electric_kiwi/fixtures/hop_intervals.json +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -1,249 +1,250 @@ { "data": { - "hop_duration": "60", "type": "hop_intervals", + "hop_duration": "60", "intervals": { "1": { - "active": 1, + "start_time": "12:00 AM", "end_time": "1:00 AM", - "start_time": "12:00 AM" + "active": 1 }, "2": { - "active": 1, + "start_time": "12:30 AM", "end_time": "1:30 AM", - "start_time": "12:30 AM" + "active": 1 }, "3": { - "active": 1, + "start_time": "1:00 AM", "end_time": "2:00 AM", - "start_time": "1:00 AM" + "active": 1 }, "4": { - "active": 1, + "start_time": "1:30 AM", "end_time": "2:30 AM", - "start_time": "1:30 AM" + "active": 1 }, "5": { - "active": 1, + "start_time": "2:00 AM", "end_time": "3:00 AM", - "start_time": "2:00 AM" + "active": 1 }, "6": { - "active": 1, + "start_time": "2:30 AM", "end_time": "3:30 AM", - "start_time": "2:30 AM" + "active": 1 }, "7": { - "active": 1, + "start_time": "3:00 AM", "end_time": "4:00 AM", - "start_time": "3:00 AM" + "active": 1 }, "8": { - "active": 1, + "start_time": "3:30 AM", "end_time": "4:30 AM", - "start_time": "3:30 AM" + "active": 1 }, "9": { - "active": 1, + "start_time": "4:00 AM", "end_time": "5:00 AM", - "start_time": "4:00 AM" + "active": 1 }, "10": { - "active": 1, + "start_time": "4:30 AM", "end_time": "5:30 AM", - "start_time": "4:30 AM" + "active": 1 }, "11": { - "active": 1, + "start_time": "5:00 AM", "end_time": "6:00 AM", - "start_time": "5:00 AM" + "active": 1 }, "12": { - "active": 1, + "start_time": "5:30 AM", "end_time": "6:30 AM", - "start_time": "5:30 AM" + "active": 1 }, "13": { - "active": 1, + "start_time": "6:00 AM", "end_time": "7:00 AM", - "start_time": "6:00 AM" + "active": 1 }, "14": { - "active": 1, + "start_time": "6:30 AM", "end_time": "7:30 AM", - "start_time": "6:30 AM" + "active": 0 }, "15": { - "active": 1, + "start_time": "7:00 AM", "end_time": "8:00 AM", - "start_time": "7:00 AM" + "active": 0 }, "16": { - "active": 1, + "start_time": "7:30 AM", "end_time": "8:30 AM", - "start_time": "7:30 AM" + "active": 0 }, "17": { - "active": 1, + "start_time": "8:00 AM", "end_time": "9:00 AM", - "start_time": "8:00 AM" + "active": 0 }, "18": { - "active": 1, + "start_time": "8:30 AM", "end_time": "9:30 AM", - "start_time": "8:30 AM" + "active": 0 }, "19": { - "active": 1, + "start_time": "9:00 AM", "end_time": "10:00 AM", - "start_time": "9:00 AM" + "active": 1 }, "20": { - "active": 1, + "start_time": "9:30 AM", "end_time": "10:30 AM", - "start_time": "9:30 AM" + "active": 1 }, "21": { - "active": 1, + "start_time": "10:00 AM", "end_time": "11:00 AM", - "start_time": "10:00 AM" + "active": 1 }, "22": { - "active": 1, + "start_time": "10:30 AM", "end_time": "11:30 AM", - "start_time": "10:30 AM" + "active": 1 }, "23": { - "active": 1, + "start_time": "11:00 AM", "end_time": "12:00 PM", - "start_time": "11:00 AM" + "active": 1 }, "24": { - "active": 1, + "start_time": "11:30 AM", "end_time": "12:30 PM", - "start_time": "11:30 AM" + "active": 1 }, "25": { - "active": 1, + "start_time": "12:00 PM", "end_time": "1:00 PM", - "start_time": "12:00 PM" + "active": 1 }, "26": { - "active": 1, + "start_time": "12:30 PM", "end_time": "1:30 PM", - "start_time": "12:30 PM" + "active": 1 }, "27": { - "active": 1, + "start_time": "1:00 PM", "end_time": "2:00 PM", - "start_time": "1:00 PM" + "active": 1 }, "28": { - "active": 1, + "start_time": "1:30 PM", "end_time": "2:30 PM", - "start_time": "1:30 PM" + "active": 1 }, "29": { - "active": 1, + "start_time": "2:00 PM", "end_time": "3:00 PM", - "start_time": "2:00 PM" + "active": 1 }, "30": { - "active": 1, + "start_time": "2:30 PM", "end_time": "3:30 PM", - "start_time": "2:30 PM" + "active": 1 }, "31": { - "active": 1, + "start_time": "3:00 PM", "end_time": "4:00 PM", - "start_time": "3:00 PM" + "active": 1 }, "32": { - "active": 1, + "start_time": "3:30 PM", "end_time": "4:30 PM", - "start_time": "3:30 PM" + "active": 1 }, "33": { - "active": 1, + "start_time": "4:00 PM", "end_time": "5:00 PM", - "start_time": "4:00 PM" + "active": 1 }, "34": { - "active": 1, + "start_time": "4:30 PM", "end_time": "5:30 PM", - "start_time": "4:30 PM" + "active": 0 }, "35": { - "active": 1, + "start_time": "5:00 PM", "end_time": "6:00 PM", - "start_time": "5:00 PM" + "active": 0 }, "36": { - "active": 1, + "start_time": "5:30 PM", "end_time": "6:30 PM", - "start_time": "5:30 PM" + "active": 0 }, "37": { - "active": 1, + "start_time": "6:00 PM", "end_time": "7:00 PM", - "start_time": "6:00 PM" + "active": 0 }, "38": { - "active": 1, + "start_time": "6:30 PM", "end_time": "7:30 PM", - "start_time": "6:30 PM" + "active": 0 }, "39": { - "active": 1, + "start_time": "7:00 PM", "end_time": "8:00 PM", - "start_time": "7:00 PM" + "active": 0 }, "40": { - "active": 1, + "start_time": "7:30 PM", "end_time": "8:30 PM", - "start_time": "7:30 PM" + "active": 0 }, "41": { - "active": 1, + "start_time": "8:00 PM", "end_time": "9:00 PM", - "start_time": "8:00 PM" + "active": 0 }, "42": { - "active": 1, + "start_time": "8:30 PM", "end_time": "9:30 PM", - "start_time": "8:30 PM" + "active": 0 }, "43": { - "active": 1, + "start_time": "9:00 PM", "end_time": "10:00 PM", - "start_time": "9:00 PM" + "active": 1 }, "44": { - "active": 1, + "start_time": "9:30 PM", "end_time": "10:30 PM", - "start_time": "9:30 PM" + "active": 1 }, "45": { - "active": 1, - "end_time": "11:00 AM", - "start_time": "10:00 PM" + "start_time": "10:00 PM", + "end_time": "11:00 PM", + "active": 1 }, "46": { - "active": 1, + "start_time": "10:30 PM", "end_time": "11:30 PM", - "start_time": "10:30 PM" + "active": 1 }, "47": { - "active": 1, + "start_time": "11:00 PM", "end_time": "12:00 AM", - "start_time": "11:00 PM" + "active": 1 }, "48": { - "active": 1, + "start_time": "11:30 PM", "end_time": "12:30 AM", - "start_time": "11:30 PM" + "active": 0 } - } + }, + "service_type": "electricity" }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/session.json b/tests/components/electric_kiwi/fixtures/session.json new file mode 100644 index 00000000000..ee04aaca549 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session.json @@ -0,0 +1,23 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [ + { + "service": "Electricity", + "identifier": "00000000DDA", + "is_primary_service": true, + "service_status": "Y" + } + ], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/session_no_services.json b/tests/components/electric_kiwi/fixtures/session_no_services.json new file mode 100644 index 00000000000..62ae7aea20a --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session_no_services.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 681320972b5..ab643a0ddf1 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -3,70 +3,40 @@ from __future__ import annotations from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from electrickiwi_api.exceptions import ApiException import pytest -from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.electric_kiwi.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, SCOPE_VALUES, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component -from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .conftest import CLIENT_ID, REDIRECT_URI from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup application credentials component.""" - await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: - """Test config flow base case with no credentials registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -76,13 +46,13 @@ async def test_full_flow( }, ) - URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + url_scope = SCOPE_VALUES.replace(" ", "+") assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}" f"&state={state}" - f"&scope={URL_SCOPE}" + f"&scope={url_scope}" ) client = await hass_client_no_auth() @@ -90,6 +60,7 @@ async def test_full_flow( assert resp.status == HTTPStatus.OK assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -106,20 +77,73 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + electrickiwi_api: AsyncMock, +) -> None: + """Check failure on creation of entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + url_scope = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={url_scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + electrickiwi_api.get_active_session.side_effect = ApiException() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "connection_error" + + @pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - config_entry: MockConfigEntry, + migrated_config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - config_entry.add_to_hass(hass) + migrated_config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN} ) state = config_entry_oauth2_flow._encode_jwt( @@ -145,7 +169,9 @@ async def test_existing_entry( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -154,13 +180,13 @@ async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, - config_entry: MockConfigEntry, - setup_credentials: None, + mock_setup_entry: AsyncMock, + migrated_config_entry: MockConfigEntry, ) -> None: """Test Electric Kiwi reauthentication.""" - config_entry.add_to_hass(hass) - result = await config_entry.start_reauth_flow(hass) + migrated_config_entry.add_to_hass(hass) + + result = await migrated_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -189,8 +215,11 @@ async def test_reauthentication( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/electric_kiwi/test_init.py b/tests/components/electric_kiwi/test_init.py new file mode 100644 index 00000000000..947f788ad55 --- /dev/null +++ b/tests/components/electric_kiwi/test_init.py @@ -0,0 +1,135 @@ +"""Test the Electric Kiwi init.""" + +import http +from unittest.mock import AsyncMock, patch + +from aiohttp import RequestInfo +from aiohttp.client_exceptions import ClientResponseError +from electrickiwi_api.exceptions import ApiException, AuthException +import pytest + +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + await init_integration(hass, config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_multiple_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_entry2: MockConfigEntry, +) -> None: + """Test a successful setup and unload of multiple entries.""" + + for entry in (config_entry, config_entry2): + await init_integration(hass, entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + for entry in (config_entry, config_entry2): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + ( + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_refresh_token_validity_failures( + hass: HomeAssistant, + config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test token refresh failure status.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status + ), + ) as mock_async_ensure_token_valid: + await init_integration(hass, config_entry) + mock_async_ensure_token_valid.assert_called_once() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_unique_id_migration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID is migrated to the customer number.""" + + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + new_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert new_entry.minor_version == 2 + assert new_entry.unique_id == "123456" + entity_entry = entity_registry.async_get( + "sensor.electric_kiwi_123456_515363_sensor" + ) + assert entity_entry.unique_id == "123456_00000000DDA_sensor" + + +async def test_unique_id_migration_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = ApiException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_unique_id_migration_auth_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = AuthException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index a85eb16a986..3e58b33a998 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import dt as dt_util -from .conftest import ComponentSetup, YieldFixture +from . import init_integration from tests.common import MockConfigEntry @@ -47,10 +47,9 @@ def restore_timezone(): async def test_hop_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: Mock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, ) -> None: @@ -61,7 +60,7 @@ async def test_hop_sensors( sensor state should be set to today at 4pm or if now is past 4pm, then tomorrow at 4pm. """ - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -70,8 +69,7 @@ async def test_hop_sensors( state = hass.states.get(sensor) assert state - api = ek_api(Mock()) - hop_data = await api.get_hop() + hop_data = await electrickiwi_api.get_hop() value = _check_and_move_time(hop_data, sensor_state) @@ -98,20 +96,19 @@ async def test_hop_sensors( ), ( "sensor.next_billing_date", - "2020-11-03T00:00:00", + "2025-02-19T00:00:00", SensorDeviceClass.DATE, None, ), - ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT), ], ) async def test_account_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: AsyncMock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, device_class: str, @@ -119,7 +116,7 @@ async def test_account_sensors( ) -> None: """Test Account sensors for the Electric Kiwi integration.""" - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -133,9 +130,9 @@ async def test_account_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == state_class -async def test_check_and_move_time(ek_api: AsyncMock) -> None: +async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" - hop = await ek_api(Mock()).get_hop() + hop = await electrickiwi_api.get_hop() test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE) From c222ffb4ecfae1946eb8e4ecd8b68f0eb46b8cf1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 5 Feb 2025 21:13:42 +0100 Subject: [PATCH 85/94] Bump holidays to 0.66 (#137449) --- 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 edf3ebe7f04..6952d48ef32 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.65", "babel==2.15.0"] + "requirements": ["holidays==0.66", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4b9d072f747..cbb11a06aec 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.65"] + "requirements": ["holidays==0.66"] } diff --git a/requirements_all.txt b/requirements_all.txt index 284afccb5f1..83b6786e1c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07ddbd78fb9..25eb1978a2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 From ab807702521c30a66c0120ccb16687efd1e05b25 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:57:15 +0100 Subject: [PATCH 86/94] Explicitly pass in the config_entry in screenlogic coordinator init (#137463) explicitly pass in the config_entry in coordinator init --- homeassistant/components/screenlogic/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index a90c9cb2cf4..b3c438dc641 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -52,6 +52,8 @@ async def async_get_connect_info( class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -60,7 +62,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): gateway: ScreenLogicGateway, ) -> None: """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry self.gateway = gateway interval = timedelta( @@ -69,6 +70,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, # Debounced option since the device takes @@ -91,7 +93,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None try: if not self.gateway.is_connected: connect_info = await async_get_connect_info( From 02482529067ac59e76617cca9895164679f3d955 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:57:41 +0100 Subject: [PATCH 87/94] Explicitly pass in the config_entry in omnilogic coordinator init (#137466) explicitly pass in the config_entry in coordinator init --- homeassistant/components/omnilogic/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 72d16f03328..24c8cdf2554 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -28,11 +30,11 @@ class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any ) -> None: """Initialize the global Omnilogic data updater.""" self.api = api - self.config_entry = config_entry super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(seconds=polling_interval), ) From 6d13aa3741d052bb247a5fe118851d7372e3aa26 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:58:01 +0100 Subject: [PATCH 88/94] Explicitly pass in the config_entry in picnic coordinator init (#137465) explicitly pass in the config_entry in coordinator init --- homeassistant/components/picnic/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index b3979580990..de686cad37d 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -21,6 +21,8 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,13 +31,13 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client - self.config_entry = config_entry self._user_address = None logger = logging.getLogger(__name__) super().__init__( hass, logger, + config_entry=config_entry, name="Picnic coordinator", update_interval=timedelta(minutes=30), ) From 12095df4fadbd506a78fdafc112dec7b5920320f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:59:26 +0100 Subject: [PATCH 89/94] Explicitly pass in the config_entry in webmin coordinator init (#137462) explicitly pass in the config_entry in coordinator init --- homeassistant/components/webmin/coordinator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 45261787e75..261139faf10 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -22,6 +22,7 @@ from .helpers import get_instance_from_options, get_sorted_mac_addresses class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The Webmin data update coordinator.""" + config_entry: ConfigEntry mac_address: str unique_id: str @@ -29,7 +30,11 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize the Webmin data update coordinator.""" super().__init__( - hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.instance, base_url = get_instance_from_options(hass, config_entry.options) @@ -53,7 +58,6 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses } else: - assert self.config_entry self.unique_id = self.config_entry.entry_id async def _async_update_data(self) -> dict[str, Any]: From db6bd6aad1d5dac3af22e487d8a65d18bbc168e4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:59:50 +0100 Subject: [PATCH 90/94] Explicitly pass in the config_entry in Bluesound coordinator init (#137461) * explicitly pass in the config_entry in coordinator init * remove unneccessary assert --- .../components/bluesound/__init__.py | 24 ++++++------------- .../components/bluesound/coordinator.py | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 6cf1957f799..37e83ce2c47 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -1,8 +1,6 @@ """The bluesound component.""" -from dataclasses import dataclass - -from pyblu import Player, SyncStatus +from pyblu import Player from pyblu.errors import PlayerUnreachableError from homeassistant.config_entries import ConfigEntry @@ -14,7 +12,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import BluesoundCoordinator +from .coordinator import ( + BluesoundConfigEntry, + BluesoundCoordinator, + BluesoundRuntimeData, +) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -23,18 +25,6 @@ PLATFORMS = [ ] -@dataclass -class BluesoundRuntimeData: - """Bluesound data class.""" - - player: Player - sync_status: SyncStatus - coordinator: BluesoundCoordinator - - -type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" return True @@ -53,7 +43,7 @@ async def async_setup_entry( except PlayerUnreachableError as ex: raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - coordinator = BluesoundCoordinator(hass, player, sync_status) + coordinator = BluesoundCoordinator(hass, config_entry, player, sync_status) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py index e62f3ef96cf..ceaf0b392eb 100644 --- a/homeassistant/components/bluesound/coordinator.py +++ b/homeassistant/components/bluesound/coordinator.py @@ -12,6 +12,7 @@ import logging from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu.errors import PlayerUnreachableError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,6 +22,15 @@ NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) +@dataclass +class BluesoundRuntimeData: + """Bluesound data class.""" + + player: Player + sync_status: SyncStatus + coordinator: BluesoundCoordinator + + @dataclass class BluesoundData: """Define a class to hold Bluesound data.""" @@ -31,6 +41,9 @@ class BluesoundData: inputs: list[Input] +type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] + + def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: """Cancel a task.""" @@ -45,8 +58,14 @@ def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]] class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): """Define an object to hold Bluesound data.""" + config_entry: BluesoundConfigEntry + def __init__( - self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + self, + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + player: Player, + sync_status: SyncStatus, ) -> None: """Initialize.""" self.player = player @@ -55,12 +74,11 @@ class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=sync_status.name, ) async def _async_setup(self) -> None: - assert self.config_entry is not None - preset = await self.player.presets() inputs = await self.player.inputs() status = await self.player.status() From 2aea078d9a4eef829dbd464bbced0701826690f0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:00:50 +0100 Subject: [PATCH 91/94] Explicitly pass in the config_entry in rympro coordinator init (#137464) explicitly pass in the config_entry in coordinator init --- homeassistant/components/rympro/__init__.py | 2 +- homeassistant/components/rympro/coordinator.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index f24735f4ed0..20d208cca69 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data={**data, CONF_TOKEN: token}, ) - coordinator = RymProDataUpdateCoordinator(hass, rympro) + coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 19f16005578..55e5f0f90df 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -7,6 +7,7 @@ import logging from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,13 +21,18 @@ _LOGGER = logging.getLogger(__name__) class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - def __init__(self, hass: HomeAssistant, rympro: RymPro) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) @@ -40,7 +46,6 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): meter_id ) except UnauthorizedError as error: - assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) raise UpdateFailed(error) from error except (CannotConnectError, OperationError) as error: From b0a82a99130c2a51eb2ad26ea2e71e0439bdfb58 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:11:17 +0100 Subject: [PATCH 92/94] Explicitly pass in the config_entry in airgradient coordinator init (#137469) --- homeassistant/components/airgradient/__init__.py | 8 ++------ homeassistant/components/airgradient/coordinator.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 3b27d6cda5e..8f7fd86847d 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from airgradient import AirGradientClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import AirGradientCoordinator +from .coordinator import AirGradientConfigEntry, AirGradientCoordinator PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -21,9 +20,6 @@ PLATFORMS: list[Platform] = [ ] -type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool: """Set up Airgradient from a config entry.""" @@ -31,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) entry.data[CONF_HOST], session=async_get_clientsession(hass) ) - coordinator = AirGradientCoordinator(hass, client) + coordinator = AirGradientCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index d2fc2a9de1b..7484c7e85a9 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -4,18 +4,17 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from airgradient import AirGradientClient, AirGradientError, Config, Measures +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import AirGradientConfigEntry +type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] @dataclass @@ -32,11 +31,17 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): config_entry: AirGradientConfigEntry _current_version: str - def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AirGradientConfigEntry, + client: AirGradientClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=f"AirGradient {client.host}", update_interval=timedelta(minutes=1), ) From bf0080cbb068f897a479cd711edb04c19f272945 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:09:54 +0100 Subject: [PATCH 93/94] Explicitly pass in the config_entry in supervisor coordinator init (#137472) --- homeassistant/components/hassio/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 2d39e740e63..833068a713c 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -295,6 +295,8 @@ def async_remove_addons_from_dev_reg( class HassioDataUpdateCoordinator(DataUpdateCoordinator): """Class to retrieve Hass.io status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry ) -> None: @@ -302,6 +304,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, # We don't want an immediate refresh since we want to avoid From 2946fbad00fd7426a3a135da83962e4b1aaa1839 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 5 Feb 2025 23:15:18 +0100 Subject: [PATCH 94/94] Bump aioairq version to 0.4.4 (#137454) --- homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 1ae7da14875..d4a6e9c295f 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.4.3"] + "requirements": ["aioairq==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83b6786e1c8..5c8bf3923c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25eb1978a2a..38d21a52091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10