From 7c778847e7dbc052d35f1d4758e8829d73be1e28 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:13:41 -0400 Subject: [PATCH 001/858] Add config flow to qBittorrent (#82560) * qbittorrent: implement config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add English translations Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: create sensors with config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: set unique_id and icon Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add tests for config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: detect duplicate config entries Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: import YAML config Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: update coveragerc Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: delete translations file * create `deprecated_yaml` issue in `setup_platform` * move qbittorrent test fixtures to conftest.py * improve code quality & remove wrong unique_id * keep PLATFORM_SCHEMA until YAML support is removed * remove CONF_NAME in config entry, fix setup_entry * improve test suite * clean up QBittorrentSensor class * improve user flow tests * explicit result assertion & minor tweaks in tests Co-authored-by: epenet * implement entry unloading Co-authored-by: epenet * add type hints * tweak config_flow data handling --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: epenet --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/qbittorrent/__init__.py | 53 +++++++ .../components/qbittorrent/config_flow.py | 76 ++++++++++ homeassistant/components/qbittorrent/const.py | 4 + .../components/qbittorrent/helpers.py | 11 ++ .../components/qbittorrent/manifest.json | 1 + .../components/qbittorrent/sensor.py | 60 ++++---- .../components/qbittorrent/strings.json | 27 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/qbittorrent/__init__.py | 1 + tests/components/qbittorrent/conftest.py | 25 ++++ .../qbittorrent/test_config_flow.py | 136 ++++++++++++++++++ 15 files changed, 377 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/qbittorrent/config_flow.py create mode 100644 homeassistant/components/qbittorrent/helpers.py create mode 100644 homeassistant/components/qbittorrent/strings.json create mode 100644 tests/components/qbittorrent/__init__.py create mode 100644 tests/components/qbittorrent/conftest.py create mode 100644 tests/components/qbittorrent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3c2c8..2c06c7d0bb8895 100644 --- a/.coveragerc +++ b/.coveragerc @@ -938,6 +938,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py diff --git a/CODEOWNERS b/CODEOWNERS index 1acd5f6c9f7bd4..509f3e5f3020a5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a92ae..5154ae155ec972 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 00000000000000..54c47c53895ade --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 5f9ad42f7fcdd0..0a79c67f400b8f 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -1,3 +1,7 @@ """Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 00000000000000..7f7833e912aff7 --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 47090ab8b91ca9..c56bb8102b8619 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,6 +2,7 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cafb8d8b21ee9f..6b758daab0ac03 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,12 +24,12 @@ UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_NAME +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,31 +70,41 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the qBittorrent sensors.""" - - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) - + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name) for description in SENSOR_TYPES + QBittorrentSensor(description, client, config_entry) + for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 00000000000000..24d1885a9177ac --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9e460..240e30ec8602c4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -339,6 +339,7 @@ "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3e89f9d12d5a11..bc0e9e1c335762 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4316,7 +4316,7 @@ "qbittorrent": { "name": "qBittorrent", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "qingping": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d3464e2..5b2f1be04c94ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1507,6 +1507,9 @@ python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 +# homeassistant.components.qbittorrent +python-qbittorrent==0.4.2 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/qbittorrent/__init__.py b/tests/components/qbittorrent/__init__.py new file mode 100644 index 00000000000000..2be020668c144e --- /dev/null +++ b/tests/components/qbittorrent/__init__.py @@ -0,0 +1 @@ +"""Tests for the qBittorrent integration.""" diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py new file mode 100644 index 00000000000000..448f68db81e388 --- /dev/null +++ b/tests/components/qbittorrent/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for testing qBittorrent component.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +import requests_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock qbittorrent entry setup.""" + with patch( + "homeassistant.components.qbittorrent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api() -> Generator[requests_mock.Mocker, None, None]: + """Mock the qbittorrent API.""" + with requests_mock.Mocker() as mocker: + mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) + mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") + mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") + yield mocker diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py new file mode 100644 index 00000000000000..b7244ccef8d747 --- /dev/null +++ b/tests/components/qbittorrent/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the qBittorrent config flow.""" +import pytest +from requests.exceptions import RequestException +import requests_mock + +from homeassistant.components.qbittorrent.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SOURCE, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, +} + +YAML_IMPORT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + + +async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: + """Test the user flow.""" + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with connection failure, fail with cannot_connect + with requests_mock.Mocker() as mock: + mock.get( + f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", + exc=RequestException, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Test flow with wrong creds, fail with invalid_auth + with requests_mock.Mocker() as mock: + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Wrong username/password", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Test flow with proper input, succeed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with duplicate config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 7010447b04be53d4c9f1cc696cd9210f87611d90 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:46:32 +0200 Subject: [PATCH 002/858] Bump version to 2023.5.0dev0 (#90477) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e4fd319e71596a..8464b1a299f51e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.4 + HA_SHORT_VERSION: 2023.5 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11fa6a..23b4a9a13293b6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 577ba181401e5f..d409ef188d11be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.5.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 43a7247ddeb9545d03cabd1ce6b2f4fc46c59663 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:37 -0300 Subject: [PATCH 003/858] Move ProxmoxEntity to entity.py (#90480) * Move ProxmoxEntity to entity.py * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 53 +------------------ .../components/proxmoxve/binary_sensor.py | 3 +- homeassistant/components/proxmoxve/entity.py | 39 ++++++++++++++ 3 files changed, 42 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/proxmoxve/entity.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2764f22b080633..b61f0ca4df5d16 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -22,10 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, @@ -252,54 +249,6 @@ def call_api_container_vm( return status -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int | None = None, - ) -> None: - """Initialize the Proxmox entity.""" - super().__init__(coordinator) - - self.coordinator = coordinator - self._unique_id = unique_id - self._name = name - self._host_name = host_name - self._icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id - - self._state = None - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available - - class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 828c8191148037..ea02e547e98370 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import COORDINATORS, DOMAIN, PROXMOX_CLIENTS, ProxmoxEntity +from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS +from .entity import ProxmoxEntity async def async_setup_platform( diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py new file mode 100644 index 00000000000000..5dfd264df2db5c --- /dev/null +++ b/homeassistant/components/proxmoxve/entity.py @@ -0,0 +1,39 @@ +"""Proxmox parent entity class.""" + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class ProxmoxEntity(CoordinatorEntity): + """Represents any entity created for the Proxmox VE platform.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, + ) -> None: + """Initialize the Proxmox entity.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._attr_unique_id = unique_id + self._attr_name = name + self._host_name = host_name + self._attr_icon = icon + self._available = True + self._node_name = node_name + self._vm_id = vm_id + + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._available From d0a492644d9773124692de286f34922b454edf53 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 004/858] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e346a..652f19d24bac79 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,19 +47,15 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" - return NetworkBackup( - node_info=app.state.node_info, - network_info=app.state.network_info, - ) - - def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: """Get the network settings for the currently active ZHA network.""" zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller - return _wrap_network_settings(zha_gateway.application_controller) + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, + ) async def async_get_last_network_settings( @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c6079099804863..59daf2179b6a46 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 3bebd4318e99e94ae82a0d941b458e4014a86b38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 005/858] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 07ecc2a1bec77c..84b5ae7e20522e 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e637da..f1ec6ba14c4622 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.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81cfb4b..bd80ded7573b9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b2f1be04c94ed..3519962af1c415 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 706e6597d8189f02d17cfb87b0ef0c4ebbc6cb5f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 006/858] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea93338161..809dc9086be590 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ class DevoloBinarySensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f6fb2..8b665d7bf0241e 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ def __init__( name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8dd9a..aeeab2ce89b038 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ class DevoloSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ class DevoloSensorEntityDescription( CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ class DevoloSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1ba40..3472886cd5b024 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985dade5..6f387fdf05f41b 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ class DevoloSwitchEntityDescription( SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ class DevoloSwitchEntityDescription( key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 1023628821e8e68b3b16b42003e276c297c364fa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 007/858] Bump reolink-aio to 0.5.8 (#90467) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 95b180fc164cdc..79fc15c571de1d 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd80ded7573b9c..cae40bd2c6d3d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3519962af1c415..b819133fc7004c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From 4c21caa917fe8f77f869c895c5b068504a27a068 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 008/858] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4ab861..73f060e79b7044 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0e6c6..8633e6ec466f5d 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ async def async_step_user( if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5d376..78ceb620e53f15 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ def is_allowed_external_url(self, url: str) -> bool: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From 93d1961aae130ce12c354c1b6c9ba6668540b699 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 009/858] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 131 ++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 532 insertions(+), 214 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3c387..489ff97eb4a6b8 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed + + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), + ) + + try: + await hass.async_add_executor_job(ezviz_client.login) - return True + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] - ) - await coordinator.async_refresh() + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} - if not coordinator.last_update_success: - raise ConfigEntryNotReady + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - undo_listener = entry.add_update_listener(_async_update_listener) + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c021571..0456e7ade9e6d7 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa5131..77598ad6a1c764 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } -def _test_camera_rtsp_creds(data): + return auth_data + + +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err + await self.hass.async_add_executor_job(ezviz_client.login) - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ async def _validate_and_create_camera_rtsp(self, data): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ async def async_step_user(self, user_input=None): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ async def async_step_user(self, user_input=None): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ async def async_step_user(self, user_input=None): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ async def async_step_user_custom_url(self, user_input=None): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ async def async_step_user_custom_url(self, user_input=None): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ async def async_step_user_custom_url(self, user_input=None): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ async def async_step_confirm(self, user_input=None): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ async def async_step_confirm(self, user_input=None): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6c3dc..d052a4b8216679 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b9441..ba8ed336a51c75 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ def __init__( super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b2f3e..5e258e42705748 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26ebc21..768fc30cc81c4b 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b7f1c..e89e375fb5ec30 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c42a5..939bb92bcc0fc9 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From fc78290e2f425aaeed11b61255b2f0adf8f3be1c Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:39 -0400 Subject: [PATCH 010/858] Remove callback decorators where unneeded (#90478) * Remove callback decorators where unneeded * revert extra replace --- homeassistant/components/zwave_js/api.py | 4 ++-- homeassistant/components/zwave_js/helpers.py | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91b1e2a71574cd..29e0dcf9e069cf 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,8 +82,8 @@ from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_update_data_collection_preference, get_device_id, - update_data_collection_preference, ) DATA_UNSUBSCRIBE = "unsubs" @@ -1860,7 +1860,7 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - update_data_collection_preference(hass, entry, opted_in) + async_update_data_collection_preference(hass, entry, opted_in) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index d856e987af7563..6c54a464837238 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -92,7 +92,6 @@ def value_matches_matcher( ) -@callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """Get the value ID and optional state key from a unique ID. @@ -106,7 +105,6 @@ def get_value_id_from_unique_id(unique_id: str) -> str | None: return None -@callback def get_state_key_from_unique_id(unique_id: str) -> int | None: """Get the state key from a unique ID.""" # If the unique ID has more than two parts, it's a special unique ID. If the last @@ -119,7 +117,6 @@ def get_state_key_from_unique_id(unique_id: str) -> int | None: return None -@callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" return value.value if value else None @@ -132,7 +129,7 @@ async def async_enable_statistics(driver: Driver) -> None: @callback -def update_data_collection_preference( +def async_update_data_collection_preference( hass: HomeAssistant, entry: ConfigEntry, preference: bool ) -> None: """Update data collection preference on config entry.""" @@ -141,7 +138,6 @@ def update_data_collection_preference( hass.config_entries.async_update_entry(entry, data=new_data) -@callback def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: """Return the base unique ID for an entity that is not based on a value.""" return f"{driver.controller.home_id}.{node.node_id}" @@ -152,13 +148,11 @@ def get_unique_id(driver: Driver, value_id: str) -> str: return f"{driver.controller.home_id}.{value_id}" -@callback def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]: """Get device registry identifier for Z-Wave node.""" return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}") -@callback def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None: """Get extended device registry identifier for Z-Wave node.""" if None in (node.manufacturer_id, node.product_type, node.product_id): @@ -171,7 +165,6 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None ) -@callback def get_home_and_node_id_from_device_entry( device_entry: dr.DeviceEntry, ) -> tuple[str, int] | None: From f0710bae06e1d02df8fa70eb758b9386517d204e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 30 Mar 2023 07:42:09 +0200 Subject: [PATCH 011/858] Add config-flow to Snapcast (#80288) * initial stab at snapcast config flow * fix linting errors * Fix linter errors * Add import flow, support unloading * Add test for import flow * Add dataclass and remove unique ID in config-flow * remove translations * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Refactor config flow and terminate connection * Rename test_config_flow.py * Fix tests * Minor fixes * Make mock_create_server a fixture * Combine tests * Abort if entry already exists * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Move HomeAssistantSnapcast to own file. Clean-up last commit * Split import flow from user flow. Fix tests. * Use explicit asserts. Add default values to dataclass * Change entry title to Snapcast --------- Co-authored-by: Barrett Lowe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 4 +- CODEOWNERS | 1 + homeassistant/components/snapcast/__init__.py | 42 ++++++- .../components/snapcast/config_flow.py | 63 ++++++++++ homeassistant/components/snapcast/const.py | 6 +- .../components/snapcast/manifest.json | 1 + .../components/snapcast/media_player.py | 87 +++++++++----- homeassistant/components/snapcast/server.py | 15 +++ .../components/snapcast/strings.json | 27 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/snapcast/__init__.py | 1 + tests/components/snapcast/conftest.py | 23 ++++ tests/components/snapcast/test_config_flow.py | 110 ++++++++++++++++++ 15 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/snapcast/config_flow.py create mode 100644 homeassistant/components/snapcast/server.py create mode 100644 homeassistant/components/snapcast/strings.json create mode 100644 tests/components/snapcast/__init__.py create mode 100644 tests/components/snapcast/conftest.py create mode 100644 tests/components/snapcast/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2c06c7d0bb8895..d313da55dd80f3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1101,7 +1101,9 @@ omit = homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* + homeassistant/components/snapcast/__init__.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/server.py homeassistant/components/snmp/device_tracker.py homeassistant/components/snmp/sensor.py homeassistant/components/snmp/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 509f3e5f3020a5..0e918caadeaf93 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1105,6 +1105,7 @@ build.json @home-assistant/supervisor /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 +/tests/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce06c2..309669a8496826 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,41 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .server import HomeAssistantSnapcast + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + try: + server = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except OSError as ex: + raise ConfigEntryNotReady( + f"Could not connect to Snapcast server at {host}:{port}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 00000000000000..896d3f8b5a8ace --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,63 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +from snapcast.control.server import CONTROL_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=CONTROL_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle first step.""" + errors = {} + if user_input: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=False + ) + except socket.gaierror: + errors["base"] = "invalid_host" + except OSError: + errors["base"] = "cannot_connect" + else: + await client.stop() + return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) + return self.async_show_form( + step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match( + { + CONF_HOST: (import_config[CONF_HOST]), + CONF_PORT: (import_config[CONF_PORT]), + } + ) + return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b910c..ded57e6fb037ea 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -1,6 +1,7 @@ """Constants for Snapcast.""" +from homeassistant.const import Platform -DATA_KEY = "snapcast" +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] GROUP_PREFIX = "snapcast_group_" GROUP_SUFFIX = "Snapcast Group" @@ -15,3 +16,6 @@ ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DOMAIN = "snapcast" +DEFAULT_TITLE = "Snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index bdcadc84e7c131..8701fca0ad46e1 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,6 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e2a42..6f965155bba4df 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging -import socket -import snapcast.control from snapcast.control.server import CONTROL_PORT import voluptuous as vol @@ -14,10 +12,12 @@ MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -25,7 +25,7 @@ ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, - DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -34,6 +34,7 @@ SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -42,18 +43,10 @@ ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) - +def register_services(): + """Register snapcast services.""" platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") platform.async_register_entity_service( @@ -66,23 +59,55 @@ async def async_setup_platform( handle_set_latency, ) - try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) - except socket.gaierror: - _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return - # Note: Host part is needed, when using multiple snapservers +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the snapcast config entry.""" + snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + + register_services() + + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - devices: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid) for group in server.groups + snapcast_data.groups = [ + SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups ] - devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients) - hass.data[DATA_KEY] = devices - async_add_entities(devices) + snapcast_data.clients = [ + SnapcastClientDevice(client, hpid, config_entry.entry_id) + for client in snapcast_data.server.clients + ] + async_add_entities(snapcast_data.clients + snapcast_data.groups) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Snapcast platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def handle_async_join(entity, service_call): @@ -211,10 +236,11 @@ class SnapcastClientDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, client, uid_part): + def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + self._entry_id = entry_id async def async_added_to_hass(self) -> None: """Subscribe to client events.""" @@ -303,9 +329,10 @@ async def async_set_volume_level(self, volume: float) -> None: async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DOMAIN][self._entry_id].clients + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise TypeError("Master is not a client device. Can only join clients.") diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py new file mode 100644 index 00000000000000..507ad6393a2e42 --- /dev/null +++ b/homeassistant/components/snapcast/server.py @@ -0,0 +1,15 @@ +"""Snapcast Integration.""" +from dataclasses import dataclass, field + +from snapcast.control import Snapserver + +from homeassistant.components.media_player import MediaPlayerEntity + + +@dataclass +class HomeAssistantSnapcast: + """Snapcast data stored in the Home Assistant data object.""" + + server: Snapserver + clients: list[MediaPlayerEntity] = field(default_factory=list) + groups: list[MediaPlayerEntity] = field(default_factory=list) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 00000000000000..0087b70d8204e0 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Snapcast YAML configuration is being removed", + "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 240e30ec8602c4..2f84b0b10d2e7a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bc0e9e1c335762..02273b8d97f88a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5061,7 +5061,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b819133fc7004c..314cd2efdfebcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1687,6 +1687,9 @@ smart-meter-texas==0.4.7 # homeassistant.components.smhi smhi-pkg==1.0.16 +# homeassistant.components.snapcast +snapcast==2.3.2 + # homeassistant.components.sonos soco==0.29.1 diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py new file mode 100644 index 00000000000000..a325bd41bd7d66 --- /dev/null +++ b/tests/components/snapcast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Snapcast integration.""" diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py new file mode 100644 index 00000000000000..00d031192d819d --- /dev/null +++ b/tests/components/snapcast/conftest.py @@ -0,0 +1,23 @@ +"""Test the snapcast config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_create_server() -> Generator[AsyncMock, None, None]: + """Create mock snapcast connection.""" + mock_connection = AsyncMock() + mock_connection.start = AsyncMock(return_value=None) + with patch("snapcast.control.create_server", return_value=mock_connection): + yield mock_connection diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py new file mode 100644 index 00000000000000..b6ff43503a6328 --- /dev/null +++ b/tests/components/snapcast/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the Snapcast module.""" + +import socket +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test we get the form and handle errors and successful connection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # test invalid host error + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # test connection error + with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # test success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert len(mock_create_server.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test config flow abort if device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONNECTION, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} From 40cb0eeb68d20fc3367a21efa1b85dda6c1f089a Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 012/858] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30e482..0ccc61e99c101b 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ async def async_step_confirm( return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761b869..a7c3f3e439cbdd 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From 053ed3cfdc0550fff487e1602018017c30162a2c Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:49:46 +0200 Subject: [PATCH 013/858] Add reauth to frontier_silicon config flow (#90443) * Add reauth to frontier_silicon config flow * Update patch target * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add reauth_successful to strings.json * Don't manually set "title_placeholders" Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../frontier_silicon/config_flow.py | 20 +++++ .../components/frontier_silicon/strings.json | 3 +- .../frontier_silicon/test_config_flow.py | 79 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0ccc61e99c101b..7067f882973616 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Frontier Silicon Media Player integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -53,6 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _name: str _webfsapi_url: str + _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" @@ -192,6 +194,16 @@ async def async_step_confirm( step_id="confirm", description_placeholders={"name": self._name} ) + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._webfsapi_url = config[CONF_WEBFSAPI_URL] + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_device_config() + async def async_step_device_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -220,6 +232,14 @@ async def async_step_device_config( _LOGGER.exception(exception) errors["base"] = "unknown" else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_PIN: user_input[CONF_PIN]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a7c3f3e439cbdd..f40abe1675285a 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -24,7 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 612058af0a1e77..524b985b125d16 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -415,3 +415,82 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_auth" + + +async def test_reauth_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_reauth_flow_friendly_name_error( + hass: HomeAssistant, + exception: Exception, + reason: str, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with failures.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] == {"base": reason} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" From ba32e28fc67fe27a0c94448dfa62deb6a4059d82 Mon Sep 17 00:00:00 2001 From: jellenijhof12 Date: Thu, 30 Mar 2023 08:59:29 +0200 Subject: [PATCH 014/858] Add dimmable lights support to niko home control (#90141) * added support for dimmable lights and auto host discover * split up merge request * fixed feedback brightness support * fixed feedback Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * resolved feedback --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/niko_home_control/light.py | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 4d12591a472e63..b541a145a66c7e 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -8,8 +8,13 @@ import nikohomecontrol import voluptuous as vol -# Import the device class from the component that you want to support -from homeassistant.components.light import PLATFORM_SCHEMA, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -52,36 +57,23 @@ async def async_setup_platform( class NikoHomeControlLight(LightEntity): """Representation of an Niko Light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, light, data): """Set up the Niko Home Control light platform.""" self._data = data self._light = light - self._unique_id = f"light-{light.id}" - self._name = light.name - self._state = light.is_on - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_unique_id = f"light-{light.id}" + self._attr_name = light.name + self._attr_is_on = light.is_on + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + if light._state["type"] == 2: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on() + self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -91,7 +83,10 @@ def turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Get the latest data from NikoHomeControl API.""" await self._data.async_update() - self._state = self._data.get_state(self._light.id) + state = self._data.get_state(self._light.id) + self._attr_is_on = state != 0 + if brightness_supported(self.supported_color_modes): + self._attr_brightness = state * 2.55 class NikoHomeControlData: @@ -122,5 +117,5 @@ def get_state(self, aid): """Find and filter state based on action id.""" for state in self.data: if state["id"] == aid: - return state["value1"] != 0 + return state["value1"] _LOGGER.error("Failed to retrieve state off unknown light") From a7040a0487cb92d92838a707760b62310255cc7b Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:07:47 +0200 Subject: [PATCH 015/858] Add Landis+Gyr MWh-readings from ultraheat-api (#89937) * Use mwh values from ultraheat api when available Remove manifest cleanup from PR Remove added device class from this PR Restore entity registry fixture Replace filter by attr_entity_registry_enabled_default * Catchup with #90182 and #90183 * Add comment explaining disabling some entities * Add parameterisation of test cases --- .../components/landisgyr_heat_meter/sensor.py | 27 +- .../snapshots/test_sensor.ambr | 307 +++++++++++++++++- .../landisgyr_heat_meter/test_sensor.py | 47 ++- 3 files changed, 369 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 244515a07d4cda..947ab2b2a8c783 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -54,6 +54,15 @@ class HeatMeterSensorEntityDescription( HEAT_METER_SENSOR_TYPES = ( + HeatMeterSensorEntityDescription( + key="heat_usage_mwh", + icon="mdi:fire", + name="Heat usage MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "heat_usage_mwh", None), + ), HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -72,6 +81,15 @@ class HeatMeterSensorEntityDescription( state_class=SensorStateClass.TOTAL, value_fn=lambda res: getattr(res, "heat_usage_gj", None), ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_mwh", + icon="mdi:fire", + name="Heat previous year MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_mwh", None), + ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", @@ -277,7 +295,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -306,6 +323,14 @@ def __init__( self.entity_description = description self._attr_device_info = device + if ( + description.native_unit_of_measurement + in {UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.MEGA_WATT_HOUR} + and self.native_value is None + ): + # Some meters will return MWh, others will return GJ. + self._attr_entity_registry_enabled_default = False + @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index 9c62ca3f94ba93..d3ab9d5ade02ab 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_create_sensors +# name: test_create_sensors[mock_heat_meter_response0] list([ StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -276,7 +276,310 @@ 'entity_id': 'sensor.heat_meter_meter_date_time', 'last_changed': , 'last_updated': , - 'state': '2022-05-20T02:41:17+00:00', + 'state': '2022-05-19T19:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_create_sensors[mock_heat_meter_response1] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage MWh', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_mwh', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat previous year MWh', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_mwh', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-19T19:41:17+00:00', }), StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 4de58a206e6d19..9f4ad241245076 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -3,6 +3,7 @@ import datetime from unittest.mock import patch +import pytest import serial from syrupy import SnapshotAssertion @@ -25,19 +26,49 @@ class MockHeatMeterResponse: """Mock for HeatMeterResponse.""" - heat_usage_gj: float + heat_usage_gj: float | None + heat_usage_mwh: float | None volume_usage_m3: float - heat_previous_year_gj: float + heat_previous_year_gj: float | None + heat_previous_year_mwh: float | None device_number: str meter_date_time: datetime.datetime +@pytest.mark.parametrize( + "mock_heat_meter_response", + [ + { + "heat_usage_gj": 123.0, + "heat_usage_mwh": None, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": 111.0, + "heat_previous_year_mwh": None, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + { + "heat_usage_gj": None, + "heat_usage_mwh": 123.0, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": None, + "heat_previous_year_mwh": 111.0, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + ], +) @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_heat_meter_response, ) -> None: """Test sensor.""" entry_data = { @@ -48,13 +79,7 @@ async def test_create_sensors( mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) mock_entry.add_to_hass(hass) - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123.0, - volume_usage_m3=456.0, - heat_previous_year_gj=111.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), - ) + mock_heat_meter_response = MockHeatMeterResponse(**mock_heat_meter_response) mock_heat_meter().read.return_value = mock_heat_meter_response @@ -79,8 +104,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # First setup normally mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=123.0, + heat_usage_mwh=None, volume_usage_m3=456.0, heat_previous_year_gj=111.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), ) @@ -106,8 +133,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # Now 'enable' and see if next poll succeeds mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=124.0, + heat_usage_mwh=None, volume_usage_m3=457.0, heat_previous_year_gj=112.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), ) From 35995153259e8a01a8a3b8594b1cb8fdb42d230a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 016/858] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e326c..16e0da0d5182ce 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 0e7d7f32c155f16090e27e65c86da2ffe77fc50b Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 30 Mar 2023 03:33:01 -0500 Subject: [PATCH 017/858] Add new control4 helper function (#90234) * Add new helper function to retrieve device variables and update light platform * seperate try catch from helper function and fix typing * Change helper function name * Remove unnecessary forced type changes * More type changes --- .../components/control4/director_utils.py | 32 ++++++++++++------- homeassistant/components/control4/light.py | 16 +++++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index bab8c8634cae8f..3d360e364389aa 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -1,5 +1,8 @@ """Provides data updates from the Control4 controller for platforms.""" +from collections import defaultdict +from collections.abc import Set import logging +from typing import Any from pyControl4.account import C4Account from pyControl4.director import C4Director @@ -15,21 +18,28 @@ _LOGGER = logging.getLogger(__name__) -async def director_update_data( - hass: HomeAssistant, entry: ConfigEntry, var: str -) -> dict: - """Retrieve data from the Control4 director for update_coordinator.""" - # possibly implement usage of director_token_expiration to start - # token refresh without waiting for error to occur +async def _update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Retrieve data from the Control4 director.""" + director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(variable_names) + result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) + for item in data: + result_dict[item["id"]][item["varName"]] = item["value"] + return dict(result_dict) + + +async def update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Try to Retrieve data from the Control4 director for update_coordinator.""" try: - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) + return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: _LOGGER.info("Updating Control4 director token") await refresh_tokens(hass, entry) - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) - return {key["id"]: key for key in data} + return await _update_variables_for_config_entry(hass, entry, variable_names) async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 574866411963f5..fde9b00aba26bb 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -24,7 +24,7 @@ from . import Control4Entity, get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN -from .director_utils import director_update_data +from .director_utils import update_variables_for_config_entry _LOGGER = logging.getLogger(__name__) @@ -47,14 +47,18 @@ async def async_setup_entry( async def async_update_data_non_dimmer(): """Fetch data from Control4 director for non-dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_NON_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err async def async_update_data_dimmer(): """Fetch data from Control4 director for dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -185,13 +189,15 @@ def _create_api_object(self): @property def is_on(self): """Return whether this light is on or off.""" - return self.coordinator.data[self._idx]["value"] > 0 + if self._is_dimmer: + return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx]["value"] * 2.55) + return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) return None @property From 196f5702b8fe23a14e23d97d89ff989b5660759e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 30 Mar 2023 12:25:14 +0300 Subject: [PATCH 018/858] Make hassfest.dependencies faster with multiprocessing (#81486) * hassfest.dependencies: split to two loops * hassfest.dependencies: use multiprocessing for import scan --- script/hassfest/dependencies.py | 86 ++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8d2f179aef4278..28c73d890af9ac 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -3,6 +3,7 @@ import ast from collections import deque +import multiprocessing from pathlib import Path from homeassistant.const import Platform @@ -227,36 +228,50 @@ def find_non_referenced_integrations( return referenced -def validate_dependencies( - integrations: dict[str, Integration], +def _compute_integration_dependencies( integration: Integration, - check_dependencies: bool, -) -> None: - """Validate all dependencies.""" +) -> tuple[str, dict[Path, set[str]] | None]: + """Compute integration dependencies.""" # Some integrations are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: - return + return (integration.domain, None) # Find usage of hass.components collector = ImportCollector(integration) collector.collect() + return (integration.domain, collector.referenced) - for domain in sorted( - find_non_referenced_integrations( - integrations, integration, collector.referenced - ) - ): - integration.add_error( - "dependencies", - f"Using component {domain} but it's not in 'dependencies' " - "or 'after_dependencies'", - ) - if check_dependencies: - _check_circular_deps( - integrations, integration.domain, integration, set(), deque() +def _validate_dependency_imports( + integrations: dict[str, Integration], +) -> None: + """Validate all dependencies.""" + + # Find integration dependencies with multiprocessing + # (because it takes some time to parse thousands of files) + with multiprocessing.Pool() as pool: + integration_imports = dict( + pool.imap_unordered( + _compute_integration_dependencies, + integrations.values(), + chunksize=10, + ) ) + for integration in integrations.values(): + referenced = integration_imports[integration.domain] + if not referenced: # Either ignored or has no references + continue + + for domain in sorted( + find_non_referenced_integrations(integrations, integration, referenced) + ): + integration.add_error( + "dependencies", + f"Using component {domain} but it's not in 'dependencies' " + "or 'after_dependencies'", + ) + def _check_circular_deps( integrations: dict[str, Integration], @@ -266,6 +281,7 @@ def _check_circular_deps( checking: deque[str], ) -> None: """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: return @@ -297,17 +313,21 @@ def _check_circular_deps( checking.remove(integration.domain) -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Handle dependencies for integrations.""" - # check for non-existing dependencies +def _validate_circular_dependencies(integrations: dict[str, Integration]) -> None: for integration in integrations.values(): - validate_dependencies( - integrations, - integration, - check_dependencies=not config.specific_integrations, + if integration.domain in IGNORE_VIOLATIONS: + continue + + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() ) - if config.specific_integrations: + +def _validate_dependencies_exist( + integrations: dict[str, Integration], +) -> None: + for integration in integrations.values(): + if not integration.manifest: continue # check that all referenced dependencies exist @@ -323,3 +343,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: integration.add_error( "dependencies", f"Dependency {dep} does not exist" ) + + +def validate( + integrations: dict[str, Integration], + config: Config, +) -> None: + """Handle dependencies for integrations.""" + _validate_dependency_imports(integrations) + + if not config.specific_integrations: + _validate_dependencies_exist(integrations) + _validate_circular_dependencies(integrations) From b316ffff9bc6efa93ff536c7bd6b631157d4a4cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:05:11 +0200 Subject: [PATCH 019/858] Rename hassfest _validate_dependencies_exist (#90503) --- script/hassfest/dependencies.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 28c73d890af9ac..c0733841ed593a 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -323,14 +323,14 @@ def _validate_circular_dependencies(integrations: dict[str, Integration]) -> Non ) -def _validate_dependencies_exist( +def _validate_dependencies( integrations: dict[str, Integration], ) -> None: + """Check that all referenced dependencies exist and are not duplicated.""" for integration in integrations.values(): if not integration.manifest: continue - # check that all referenced dependencies exist after_deps = integration.manifest.get("after_dependencies", []) for dep in integration.manifest.get("dependencies", []): if dep in after_deps: @@ -353,5 +353,5 @@ def validate( _validate_dependency_imports(integrations) if not config.specific_integrations: - _validate_dependencies_exist(integrations) + _validate_dependencies(integrations) _validate_circular_dependencies(integrations) From ead88cc3f8e27169129af86cb6d1400156e68da8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:54:12 +0200 Subject: [PATCH 020/858] Add preferred wind speed unit to unit systems (#90504) * Add preferred wind speed unit to unit systems * Tweak * Update tests --- homeassistant/util/unit_system.py | 12 ++++++++++ tests/util/test_unit_system.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 2a7af577769f0d..c9da324e8a5e52 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -277,6 +277,12 @@ def _deprecated_unit_system(value: str) -> str: ("water", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert wind speeds except knots to km/h + **{ + ("wind_speed", unit): UnitOfSpeed.KILOMETERS_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) + }, }, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, @@ -341,6 +347,12 @@ def _deprecated_unit_system(value: str) -> str: # Convert non-USCS volumes of water meters ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + # Convert wind speeds except knots to mph + **{ + ("wind_speed", unit): UnitOfSpeed.MILES_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) + }, }, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 01aa1256fd67b7..44b287bd05dc0e 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -457,6 +457,25 @@ def test_get_unit_system_invalid(key: str) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_metric_converted_unit_( @@ -657,6 +676,25 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_us_converted_unit( From 8d21e2b168c995346c8c6af7fe077ca0e97e6ab3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Mar 2023 13:11:33 +0200 Subject: [PATCH 021/858] Use metric units internally in Accuweather integration (#90444) * Use metric units internally * Remove unnecessary code * Simplify sensor classes * Remove AccuWeatherForecastSensor class * Update wind speed value in test * Return suggested_unit_of_measurement for wind entities * Clean test * Use _attr_suggested_unit_of_measurement * Remove _get_suggested_unit() * Remove unnecessarey code --- .../components/accuweather/__init__.py | 7 +- homeassistant/components/accuweather/const.py | 1 - .../components/accuweather/sensor.py | 162 +++++++----------- .../components/accuweather/weather.py | 45 ++--- tests/components/accuweather/test_sensor.py | 16 +- 5 files changed, 82 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f87324d..4a015728d6faa9 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -116,11 +115,7 @@ async def _async_update_data(self) -> dict[str, Any]: async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f4153ae..87bc8eaef8910f 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418c92b..4d58919947ec62 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,8 +59,6 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( @@ -72,7 +68,7 @@ class AccuWeatherSensorDescription( name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +76,7 @@ class AccuWeatherSensorDescription( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,7 +84,7 @@ class AccuWeatherSensorDescription( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -96,7 +92,7 @@ class AccuWeatherSensorDescription( icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,7 +100,7 @@ class AccuWeatherSensorDescription( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -112,7 +108,7 @@ class AccuWeatherSensorDescription( icon="mdi:vector-triangle", name="Ozone", entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -121,56 +117,52 @@ class AccuWeatherSensorDescription( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,7 +170,7 @@ class AccuWeatherSensorDescription( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -186,7 +178,7 @@ class AccuWeatherSensorDescription( icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -194,9 +186,8 @@ class AccuWeatherSensorDescription( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +195,24 @@ class AccuWeatherSensorDescription( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +224,8 @@ class AccuWeatherSensorDescription( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +233,8 @@ class AccuWeatherSensorDescription( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +244,7 @@ class AccuWeatherSensorDescription( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +252,16 @@ class AccuWeatherSensorDescription( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +269,16 @@ class AccuWeatherSensorDescription( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +288,7 @@ class AccuWeatherSensorDescription( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +296,7 @@ class AccuWeatherSensorDescription( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +305,8 @@ class AccuWeatherSensorDescription( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +314,16 @@ class AccuWeatherSensorDescription( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +331,8 @@ class AccuWeatherSensorDescription( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +352,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +391,27 @@ def __init__( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +429,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad5ff6..76a5d62a107f20 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ def condition(self) -> str | None: @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ def humidity(self) -> int: @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,9 +107,7 @@ def wind_bearing(self) -> int: @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def ozone(self) -> int | None: diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index e4f564f1335a90..29f698ca52a2d9 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -741,11 +741,21 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "10500.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" + assert state.state == "10498.687664042" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET + state = hass.states.get("sensor.home_wind") + assert state + assert state.state == "9.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR + + state = hass.states.get("sensor.home_realfeel_temperature") + assert state + assert state.state == "77.2" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT + ) + async def test_state_update(hass: HomeAssistant) -> None: """Ensure the sensor state changes after updating the data.""" From 642984a04272ce83064e5b3fc31f200cf98872de Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 022/858] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee85804..36e0a597b87a83 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e149e..f185191d1bfd6b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 976efb437b68afd32a275fde0630c956f029f9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 023/858] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90cd667..786ea55b34fbeb 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a8e96..aca0d5e5d96645 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860a309..212db0de06f1b8 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f5934002..c7bdd78188d170 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 0b72cc9f5ef4494fa2d0e7e52ca21894626b21e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 024/858] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27dac18..6f76142106ad8b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ def _async_generate_prompt(self, raw_prompt: str) -> str: return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b041e..46f8603c5f1641 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 87c46595207ce7b4baf74adcfbce26b87e75b7f0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 025/858] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 39 +++++++++++++---------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4a7c1..8a7985fe28c368 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,6 +147,23 @@ async def handle_webhook( hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) + webhook_url = webhook.async_generate_path(entry.entry_id) + hass_url = get_url( + hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False + ) + url = f"{hass_url}{webhook_url}" + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + async def _stop_nuki(_: Event): """Stop and remove the Nuki webhook.""" webhook.async_unregister(hass, entry.entry_id) @@ -155,29 +173,16 @@ async def _stop_nuki(_: Event): _remove_webhook, bridge, entry.entry_id ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + _LOGGER.error("Error communicating with bridge: %s", err) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) ) - webhook_url = webhook.async_generate_path(entry.entry_id) - hass_url = get_url( - hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False - ) - url = f"{hass_url}{webhook_url}" - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _register_webhook, bridge, entry.entry_id, url - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { From cf628dbf23c3e149e2ed904553fc58b7267ab8cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 026/858] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069fa50..8a253566e20c0c 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ def __init__( self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From fd55d0f2ddcea0eb1fb24d89501db09ca627d109 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 027/858] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe26716c..4e3c7166bf04bf 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ def parse(value: bool | int) -> bool: """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e7238..ec25295ed5a5d3 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 565f311f5c7b7d20adee8e0445ae5ff48eb6cd4a Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 30 Mar 2023 19:37:03 +0200 Subject: [PATCH 028/858] Add EV charging remote services for BMW/Mini (#88759) * Add select for EV charging to bmw_connected_drive * Use snapshot for select tests, split select_option tests * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Further adjustments from code review --------- Co-authored-by: rikroe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/select.py | 139 + .../bmw_connected_drive/conftest.py | 9 + ...x-crccs_v2_vehicles_WBA00000000DEMO02.json | 80 + .../G26/bmw-eadrax-vcs_v4_vehicles.json | 50 + ...s_v4_vehicles_state_WBA00000000DEMO02.json | 313 ++ .../snapshots/test_diagnostics.ambr | 4454 +++++++++++++---- .../snapshots/test_select.ambr | 97 + .../bmw_connected_drive/test_select.py | 84 + 9 files changed, 4126 insertions(+), 1101 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/select.py create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_select.ambr create mode 100644 tests/components/bmw_connected_drive/test_select.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed591a61..e91943034dfc6c 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 00000000000000..e8e8dd5ca40769 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,139 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + # --- Generic --- + "target_soc": BMWSelectEntityDescription( + key="target_soc", + name="Target SoC", + is_available=lambda v: v.is_remote_set_target_soc_enabled, + options=[str(i * 5 + 20) for i in range(17)], + current_option=lambda v: str(v.fuel_and_battery.charging_target), + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + unit_of_measurement=PERCENTAGE, + ), + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 887df4da6039c2..73e8f9a9b92183 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,6 +1,9 @@ """Fixtures for BMW tests.""" +from unittest.mock import AsyncMock + from bimmer_connected.api.authentication import MyBMWAuthentication +from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest from . import mock_login, mock_vehicles @@ -11,5 +14,11 @@ async def bmw_fixture(monkeypatch): """Patch the MyBMW Login and mock HTTP calls.""" monkeypatch.setattr(MyBMWAuthentication, "login", mock_login) + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), + ) + with mock_vehicles(): yield mock_vehicles diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json new file mode 100644 index 00000000000000..af850f1ff2c649 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json @@ -0,0 +1,80 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "chargingMode": "Sofort laden", + "chargingModeSemantics": "Sofort laden", + "departureTimer": ["Aus"], + "departureTimerSemantics": "Aus", + "preconditionForDeparture": "Aus", + "showDepartureTimers": false + }, + "chargingFlap": { + "permanentlyUnlockLabel": "Aus" + }, + "chargingSettings": { + "acCurrentLimitLabel": "16A", + "acCurrentLimitLabelSemantics": "16 Ampere", + "chargingTargetLabel": "80%", + "dcLoudnessLabel": "Nicht begrenzt", + "unlockCableAutomaticallyLabel": "Aus" + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "NO_PRESELECTION", + "endTimeSlot": "0001-01-01T00:00:00", + "startTimeSlot": "0001-01-01T00:00:00", + "type": "CHARGING_IMMEDIATELY" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [], + "id": 1, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 2, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "chargingFlapDetail": { + "isPermanentlyUnlock": false + }, + "chargingSettingsDetail": { + "acLimit": { + "current": { + "unit": "A", + "value": 16 + }, + "isUnlimited": false, + "max": 32, + "min": 6, + "values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32] + }, + "chargingTarget": 80, + "dcLoudness": "UNLIMITED_LOUD", + "isUnlockCableActive": false, + "minChargingTargetToWarning": 0 + }, + "servicePack": "WAVE_01" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json new file mode 100644 index 00000000000000..f954fb103ae430 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json @@ -0,0 +1,50 @@ +[ + { + "appVehicleType": "DEMO", + "attributes": { + "a4aType": "NOT_SUPPORTED", + "bodyType": "G26", + "brand": "BMW", + "color": 4284245350, + "countryOfOrigin": "DE", + "driveTrain": "ELECTRIC", + "driverGuideInfo": { + "androidAppScheme": "com.bmwgroup.driversguide.row", + "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", + "iosAppScheme": "bmwdriversguide:///open", + "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" + }, + "headUnitRaw": "HU_MGU", + "headUnitType": "MGU", + "hmiVersion": "ID8", + "lastFetched": "2023-01-04T14:57:06.019Z", + "model": "i4 eDrive40", + "softwareVersionCurrent": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "softwareVersionExFactory": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "telematicsUnit": "WAVE01", + "year": 2021 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "lmmStatusReasons": [], + "mappingStatus": "CONFIRMED" + }, + "vin": "WBA00000000DEMO02" + } +] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json new file mode 100644 index 00000000000000..8a0be88edfe8f5 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json @@ -0,0 +1,313 @@ +{ + "capabilities": { + "a4aType": "NOT_SUPPORTED", + "checkSustainabilityDPP": false, + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "digitalKey": { + "bookedServicePackage": "SMACC_1_5", + "readerGraphics": "readerGraphics", + "state": "ACTIVATED" + }, + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": true, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": true, + "isChargingLoudnessEnabled": true, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": true, + "isChargingSettingsEnabled": true, + "isChargingTargetSocEnabled": true, + "isClimateTimerWeeklyActive": false, + "isCustomerEsimSupported": true, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": true, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isPersonalPictureUploadSupported": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": true, + "isSustainabilityAccumulatedViewEnabled": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remote360": true, + "remoteChargingCommands": {}, + "remoteSoftwareUpgrade": true, + "sendPoi": true, + "specialThemeSupport": [], + "speechThirdPartyAlexa": false, + "speechThirdPartyAlexaSDK": false, + "unlock": true, + "vehicleFinder": true, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "IMMEDIATE_CHARGING", + "chargingPreference": "NO_PRESELECTION", + "chargingSettings": { + "acCurrentLimit": 16, + "hospitality": "NO_ACTION", + "idcc": "UNLIMITED_LOUD", + "targetSoc": 80 + }, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + } + ] + }, + "checkControlMessages": [ + { + "severity": "LOW", + "type": "TIRE_PRESSURE" + } + ], + "climateControlState": { + "activity": "STANDBY" + }, + "climateTimers": [ + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": false, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + } + ], + "combustionFuelLevel": {}, + "currentMileage": 1121, + "doorsState": { + "combinedSecurityState": "LOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "UNKNOWN", + "chargingLevelPercent": 80, + "chargingStatus": "INVALID", + "chargingTarget": 80, + "isChargerConnected": false, + "range": 472, + "remainingChargingMinutes": 10 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2023-01-04T14:57:06.386Z", + "lastUpdatedAt": "2023-01-04T14:57:06.407Z", + "location": { + "address": { + "formatted": "Am Olympiapark 1, 80809 München" + }, + "coordinates": { + "latitude": 48.177334, + "longitude": 11.556274 + }, + "heading": 180 + }, + "range": 472, + "requiredServices": [ + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_TUV" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "status": "OK", + "type": "TIRE_WEAR_REAR" + }, + { + "status": "OK", + "type": "TIRE_WEAR_FRONT" + } + ], + "tireState": { + "frontLeft": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 4021, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 241, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "frontRight": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 2419, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 255, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "rearLeft": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 324, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + }, + "rearRight": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 331, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + } + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 349706f593de11..2cd6622d14e832 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -22,9 +22,6 @@ 'charging_mode', 'charging_preferences', 'is_pre_entry_climatization_enabled', - 'remaining_fuel', - 'remaining_range_fuel', - 'remaining_fuel_percent', 'condition_based_services', 'check_control_messages', 'door_lock_state', @@ -34,37 +31,41 @@ ]), 'brand': 'bmw', 'charging_profile': dict({ - 'ac_available_limits': None, - 'ac_current_limit': None, - 'charging_mode': 'DELAYED_CHARGING', - 'charging_preferences': 'CHARGING_WINDOW', - 'charging_preferences_service_pack': 'TCB1', + 'ac_available_limits': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + 'ac_current_limit': 16, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': 'WAVE_01', 'departure_times': list([ dict({ '_timer_dict': dict({ 'action': 'DEACTIVATE', 'id': 1, 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, + 'hour': 0, + 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), }), 'action': 'DEACTIVATE', - 'start_time': '07:35:00', + 'start_time': '00:00:00', 'timer_id': 1, 'weekdays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), }), dict({ @@ -72,30 +73,16 @@ 'action': 'DEACTIVATE', 'id': 2, 'timeStamp': dict({ - 'hour': 18, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), }), 'action': 'DEACTIVATE', - 'start_time': '18:00:00', + 'start_time': '00:00:00', 'timer_id': 2, 'weekdays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), }), dict({ @@ -103,14 +90,14 @@ 'action': 'DEACTIVATE', 'id': 3, 'timeStamp': dict({ - 'hour': 7, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ ]), }), 'action': 'DEACTIVATE', - 'start_time': '07:00:00', + 'start_time': '00:00:00', 'timer_id': 3, 'weekdays': list([ ]), @@ -119,11 +106,15 @@ '_timer_dict': dict({ 'action': 'DEACTIVATE', 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), 'timerWeekDays': list([ ]), }), 'action': 'DEACTIVATE', - 'start_time': None, + 'start_time': '00:00:00', 'timer_id': 4, 'weekdays': list([ ]), @@ -132,185 +123,219 @@ 'is_pre_entry_climatization_enabled': False, 'preferred_charging_window': dict({ '_window_dict': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), }), - 'end_time': '01:30:00', - 'start_time': '18:01:00', + 'end_time': '00:00:00', + 'start_time': '00:00:00', }), 'timer_type': 'WEEKLY_PLANNER', }), 'check_control_messages': dict({ 'has_check_control_messages': False, 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), ]), }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ dict({ - 'due_date': '2022-10-01T00:00:00+00:00', + 'due_date': '2024-12-01T00:00:00+00:00', 'due_distance': list([ - None, - None, + 50000, + 'km', ]), 'service_type': 'BRAKE_FLUID', 'state': 'OK', }), dict({ - 'due_date': '2023-05-01T00:00:00+00:00', + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, 'due_distance': list([ None, None, ]), - 'service_type': 'VEHICLE_CHECK', + 'service_type': 'TIRE_WEAR_REAR', 'state': 'OK', }), dict({ - 'due_date': '2023-05-01T00:00:00+00:00', + 'due_date': None, 'due_distance': list([ None, None, ]), - 'service_type': 'VEHICLE_TUV', + 'service_type': 'TIRE_WEAR_FRONT', 'state': 'OK', }), ]), }), 'data': dict({ - 'appVehicleType': 'CONNECTED', + 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', 'driverGuideInfo': dict({ 'androidAppScheme': 'com.bmwgroup.driversguide.row', 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', - 'model': 'i3 (+ REX)', + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', 'softwareVersionCurrent': dict({ - 'iStep': 510, + 'iStep': 470, 'puStep': dict({ 'month': 11, 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), 'softwareVersionExFactory': dict({ - 'iStep': 502, + 'iStep': 470, 'puStep': dict({ - 'month': 3, - 'year': 15, + 'month': 11, + 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), - 'year': 2015, + 'telematicsUnit': 'WAVE01', + 'year': 2021, }), 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, 'climateFunction': 'AIR_CONDITIONING', 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), 'horn': True, 'isBmwChargingSupported': True, 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, + 'isChargeNowForBusinessSupported': True, 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, 'isDCSContractManagementSupported': True, 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, + 'isEasyChargeEnabled': True, 'isEvGoChargingSupported': False, 'isMiniChargingSupported': False, 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, 'isRemoteEngineStartSupported': False, 'isRemoteHistoryDeletionSupported': False, 'isRemoteHistorySupported': True, 'isRemoteParkingSupported': False, 'isRemoteServicesActivationRequired': False, 'isRemoteServicesBookingRequired': False, - 'isScanAndChargeSupported': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, 'isSustainabilitySupported': False, 'isWifiHotspotServiceSupported': False, 'lastStateCallState': 'ACTIVATED', 'lights': True, 'lock': True, + 'remote360': True, 'remoteChargingCommands': dict({ }), + 'remoteSoftwareUpgrade': True, 'sendPoi': True, 'specialThemeSupport': list([ ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, 'unlock': True, - 'vehicleFinder': False, + 'vehicleFinder': True, 'vehicleStateSource': 'LAST_STATE_CALL', }), 'charging_settings': dict({ 'chargeAndClimateSettings': dict({ 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', 'showDepartureTimers': False, }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), }), 'chargeAndClimateTimerDetail': dict({ 'chargingMode': dict({ - 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', }), 'departureTimer': dict({ 'type': 'WEEKLY_DEPARTURE_TIMER', 'weeklyTimers': list([ dict({ 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), 'id': 1, - 'time': '0001-01-01T07:35:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), 'id': 2, - 'time': '0001-01-01T18:00:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ 'daysOfTheWeek': list([ ]), 'id': 3, - 'time': '0001-01-01T07:00:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ @@ -324,7 +349,40 @@ }), 'isPreconditionForDepartureActive': False, }), - 'servicePack': 'TCB1', + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', 'is_metric': True, @@ -332,57 +390,47 @@ 'isAssociated': False, 'isLmmEnabled': False, 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), 'mappingStatus': 'CONFIRMED', }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', - 'chargingMode': 'DELAYED_CHARGING', - 'chargingPreference': 'CHARGING_WINDOW', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', 'chargingSettings': dict({ + 'acCurrentLimit': 16, 'hospitality': 'NO_ACTION', - 'idcc': 'NO_ACTION', - 'targetSoc': 100, + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, }), - 'climatisationOn': False, 'departureTimes': list([ dict({ 'action': 'DEACTIVATE', 'id': 1, 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, + 'hour': 0, + 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), }), dict({ 'action': 'DEACTIVATE', 'id': 2, 'timeStamp': dict({ - 'hour': 18, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), }), dict({ 'action': 'DEACTIVATE', 'id': 3, 'timeStamp': dict({ - 'hour': 7, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ @@ -391,67 +439,61 @@ dict({ 'action': 'DEACTIVATE', 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), 'timerWeekDays': list([ ]), }), ]), - 'reductionOfChargeCurrent': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), - }), }), 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), 'climateTimers': list([ dict({ 'departureTime': dict({ - 'hour': 6, - 'minute': 40, + 'hour': 0, + 'minute': 0, }), - 'isWeeklyTimer': True, - 'timerAction': 'ACTIVATE', + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'THURSDAY', - 'SUNDAY', ]), }), dict({ 'departureTime': dict({ - 'hour': 12, - 'minute': 50, + 'hour': 0, + 'minute': 0, }), - 'isWeeklyTimer': False, - 'timerAction': 'ACTIVATE', + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'MONDAY', ]), }), dict({ 'departureTime': dict({ - 'hour': 18, - 'minute': 59, + 'hour': 0, + 'minute': 0, }), 'isWeeklyTimer': True, 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'WEDNESDAY', ]), }), ]), 'combustionFuelLevel': dict({ - 'range': 105, - 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), - 'currentMileage': 137009, + 'currentMileage': 1121, 'doorsState': dict({ - 'combinedSecurityState': 'UNLOCKED', + 'combinedSecurityState': 'LOCKED', 'combinedState': 'CLOSED', 'hood': 'CLOSED', 'leftFront': 'CLOSED', @@ -464,46 +506,157 @@ 'lscPrivacyMode': 'OFF', }), 'electricChargingState': dict({ - 'chargingConnectionType': 'CONDUCTIVE', - 'chargingLevelPercent': 82, - 'chargingStatus': 'WAITING_FOR_CHARGING', - 'chargingTarget': 100, - 'isChargerConnected': True, - 'range': 174, + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, }), 'isLeftSteering': True, 'isLscSupported': True, - 'lastFetched': '2022-06-22T14:24:23.982Z', - 'lastUpdatedAt': '2022-06-22T13:58:52Z', - 'range': 174, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, 'requiredServices': list([ dict({ - 'dateTime': '2022-10-01T00:00:00.000Z', - 'description': 'Next service due by the specified date.', + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, 'status': 'OK', 'type': 'BRAKE_FLUID', }), dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next vehicle check due after the specified distance or date.', + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, 'status': 'OK', 'type': 'VEHICLE_CHECK', }), dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next state inspection due by the specified date.', 'status': 'OK', - 'type': 'VEHICLE_TUV', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', }), ]), - 'roofState': dict({ - 'roofState': 'CLOSED', - 'roofStateType': 'SUN_ROOF', + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), }), 'windowsState': dict({ 'combinedState': 'CLOSED', 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', }), }), 'vin': '**REDACTED**', @@ -511,7 +664,7 @@ 'doors_and_windows': dict({ 'all_lids_closed': True, 'all_windows_closed': True, - 'door_lock_state': 'UNLOCKED', + 'door_lock_state': 'LOCKED', 'lids': list([ dict({ 'is_closed': True, @@ -543,11 +696,6 @@ 'name': 'trunk', 'state': 'CLOSED', }), - dict({ - 'is_closed': True, - 'name': 'sunRoof', - 'state': 'CLOSED', - }), ]), 'open_lids': list([ ]), @@ -559,14 +707,29 @@ 'name': 'leftFront', 'state': 'CLOSED', }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), dict({ 'is_closed': True, 'name': 'rightFront', 'state': 'CLOSED', }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), ]), }), - 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train': 'ELECTRIC', 'drive_train_attributes': list([ 'remaining_range_total', 'mileage', @@ -584,9 +747,6 @@ 'charging_mode', 'charging_preferences', 'is_pre_entry_climatization_enabled', - 'remaining_fuel', - 'remaining_range_fuel', - 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ 'account_timezone': dict({ @@ -599,148 +759,310 @@ 'UTC', ]), }), - 'charging_end_time': None, - 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', - 'charging_status': 'WAITING_FOR_CHARGING', - 'charging_target': 100, - 'is_charger_connected': True, - 'remaining_battery_percent': 82, + 'charging_end_time': '2022-07-10T11:10:00+00:00', + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': 'NOT_CHARGING', + 'charging_target': 80, + 'is_charger_connected': False, + 'remaining_battery_percent': 80, 'remaining_fuel': list([ - 6, - 'L', + None, + None, ]), - 'remaining_fuel_percent': 65, + 'remaining_fuel_percent': None, 'remaining_range_electric': list([ - 174, + 472, 'km', ]), 'remaining_range_fuel': list([ - 105, - 'km', + None, + None, ]), 'remaining_range_total': list([ - 279, + 472, 'km', ]), }), - 'has_combustion_drivetrain': True, + 'has_combustion_drivetrain': False, 'has_electric_drivetrain': True, 'is_charging_plan_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, 'is_remote_climate_start_enabled': True, - 'is_remote_climate_stop_enabled': False, + 'is_remote_climate_stop_enabled': True, 'is_remote_horn_enabled': True, 'is_remote_lights_enabled': True, 'is_remote_lock_enabled': True, 'is_remote_sendpoi_enabled': True, - 'is_remote_set_ac_limit_enabled': False, - 'is_remote_set_target_soc_enabled': False, + 'is_remote_set_ac_limit_enabled': True, + 'is_remote_set_target_soc_enabled': True, 'is_remote_unlock_enabled': True, 'is_vehicle_active': False, - 'is_vehicle_tracking_enabled': False, + 'is_vehicle_tracking_enabled': True, 'lsc_type': 'ACTIVATED', 'mileage': list([ - 137009, + 1121, 'km', ]), - 'name': 'i3 (+ REX)', - 'timestamp': '2022-07-10T09:25:53+00:00', + 'name': 'i4 eDrive40', + 'timestamp': '2023-01-04T14:57:06+00:00', 'vehicle_location': dict({ 'account_region': 'row', - 'heading': None, - 'location': None, + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), 'remote_service_position': None, - 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', }), 'vin': '**REDACTED**', }), - ]), - 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, }), - 'seriesCluster': 'I001', + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, }), - 'seriesCluster': 'I001', + 'timerWeekDays': list([ + ]), }), - 'year': 2015, + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), }), - 'vin': '**REDACTED**', + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': dict({ - 'capabilities': dict({ - 'climateFunction': 'AIR_CONDITIONING', - 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', - 'horn': True, - 'isBmwChargingSupported': True, - 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, - 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, - 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, - 'isDCSContractManagementSupported': True, - 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, - 'isEvGoChargingSupported': False, - 'isMiniChargingSupported': False, - 'isNonLscFeatureEnabled': False, - 'isRemoteEngineStartSupported': False, - 'isRemoteHistoryDeletionSupported': False, + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, 'isRemoteHistorySupported': True, 'isRemoteParkingSupported': False, 'isRemoteServicesActivationRequired': False, @@ -760,6 +1082,76 @@ 'vehicleFinder': False, 'vehicleStateSource': 'LAST_STATE_CALL', }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -932,284 +1324,1188 @@ 'rightFront': 'CLOSED', }), }), + 'vin': '**REDACTED**', }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', - }), - dict({ - 'content': dict({ - 'chargeAndClimateSettings': dict({ - 'chargeAndClimateTimer': dict({ - 'showDepartureTimers': False, + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', }), - }), - 'chargeAndClimateTimerDetail': dict({ - 'chargingMode': dict({ - 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', }), - 'departureTimer': dict({ - 'type': 'WEEKLY_DEPARTURE_TIMER', - 'weeklyTimers': list([ - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - 'id': 1, - 'time': '0001-01-01T07:35:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - 'id': 2, - 'time': '0001-01-01T18:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 3, - 'time': '0001-01-01T07:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 4, - 'time': '0001-01-01T00:00:00', - 'timerAction': 'DEACTIVATE', - }), - ]), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', }), - 'isPreconditionForDepartureActive': False, - }), - 'servicePack': 'TCB1', - }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', - }), - ]), - 'info': dict({ - 'password': '**REDACTED**', - 'refresh_token': '**REDACTED**', - 'region': 'rest_of_world', - 'username': '**REDACTED**', - }), - }) -# --- -# name: test_device_diagnostics - dict({ - 'data': dict({ - 'available_attributes': list([ - 'gps_position', - 'vin', - 'remaining_range_total', - 'mileage', - 'charging_time_remaining', - 'charging_start_time', - 'charging_end_time', - 'charging_time_label', - 'charging_status', - 'connection_status', - 'remaining_battery_percent', - 'remaining_range_electric', - 'last_charging_end_result', - 'ac_current_limit', - 'charging_target', - 'charging_mode', - 'charging_preferences', - 'is_pre_entry_climatization_enabled', - 'remaining_fuel', - 'remaining_range_fuel', - 'remaining_fuel_percent', - 'condition_based_services', - 'check_control_messages', - 'door_lock_state', - 'timestamp', - 'lids', - 'windows', - ]), - 'brand': 'bmw', - 'charging_profile': dict({ - 'ac_available_limits': None, - 'ac_current_limit': None, - 'charging_mode': 'DELAYED_CHARGING', - 'charging_preferences': 'CHARGING_WINDOW', - 'charging_preferences_service_pack': 'TCB1', - 'departure_times': list([ - dict({ - '_timer_dict': dict({ - 'action': 'DEACTIVATE', - 'id': 1, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', }), - 'action': 'DEACTIVATE', - 'start_time': '07:35:00', - 'timer_id': 1, - 'weekdays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - }), - dict({ - '_timer_dict': dict({ - 'action': 'DEACTIVATE', - 'id': 2, - 'timeStamp': dict({ - 'hour': 18, - 'minute': 0, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', }), - 'action': 'DEACTIVATE', - 'start_time': '18:00:00', - 'timer_id': 2, - 'weekdays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - }), - dict({ - '_timer_dict': dict({ - 'action': 'DEACTIVATE', - 'id': 3, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 0, - }), - 'timerWeekDays': list([ - ]), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', }), - 'action': 'DEACTIVATE', - 'start_time': '07:00:00', - 'timer_id': 3, - 'weekdays': list([ - ]), - }), - dict({ - '_timer_dict': dict({ - 'action': 'DEACTIVATE', - 'id': 4, - 'timerWeekDays': list([ - ]), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', }), - 'action': 'DEACTIVATE', - 'start_time': None, - 'timer_id': 4, - 'weekdays': list([ - ]), - }), - ]), - 'is_pre_entry_climatization_enabled': False, - 'preferred_charging_window': dict({ - '_window_dict': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', }), - 'start': dict({ - 'hour': 18, - 'minute': 1, + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', }), - }), - 'end_time': '01:30:00', - 'start_time': '18:01:00', + ]), }), - 'timer_type': 'WEEKLY_PLANNER', - }), - 'check_control_messages': dict({ - 'has_check_control_messages': False, - 'messages': list([ + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', ]), - }), - 'condition_based_services': dict({ - 'is_service_required': False, - 'messages': list([ - dict({ - 'due_date': '2022-10-01T00:00:00+00:00', - 'due_distance': list([ - None, - None, - ]), - 'service_type': 'BRAKE_FLUID', - 'state': 'OK', - }), - dict({ - 'due_date': '2023-05-01T00:00:00+00:00', - 'due_distance': list([ - None, - None, - ]), - 'service_type': 'VEHICLE_CHECK', - 'state': 'OK', - }), - dict({ - 'due_date': '2023-05-01T00:00:00+00:00', - 'due_distance': list([ - None, - None, + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', ]), - 'service_type': 'VEHICLE_TUV', - 'state': 'OK', - }), - ]), - }), - 'data': dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + ]), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'data': dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -1217,92 +2513,1070 @@ 'month': 11, 'year': 21, }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'capabilities': dict({ - 'climateFunction': 'AIR_CONDITIONING', - 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', - 'horn': True, - 'isBmwChargingSupported': True, - 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, - 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, - 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, - 'isDCSContractManagementSupported': True, - 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, - 'isEvGoChargingSupported': False, - 'isMiniChargingSupported': False, - 'isNonLscFeatureEnabled': False, - 'isRemoteEngineStartSupported': False, - 'isRemoteHistoryDeletionSupported': False, - 'isRemoteHistorySupported': True, - 'isRemoteParkingSupported': False, - 'isRemoteServicesActivationRequired': False, - 'isRemoteServicesBookingRequired': False, - 'isScanAndChargeSupported': False, - 'isSustainabilitySupported': False, - 'isWifiHotspotServiceSupported': False, - 'lastStateCallState': 'ACTIVATED', - 'lights': True, - 'lock': True, - 'remoteChargingCommands': dict({ }), - 'sendPoi': True, - 'specialThemeSupport': list([ - ]), - 'unlock': True, - 'vehicleFinder': False, - 'vehicleStateSource': 'LAST_STATE_CALL', }), - 'charging_settings': dict({ + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ 'chargeAndClimateSettings': dict({ 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', 'showDepartureTimers': False, }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), }), 'chargeAndClimateTimerDetail': dict({ 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', - }), - 'departureTimer': dict({ - 'type': 'WEEKLY_DEPARTURE_TIMER', - 'weeklyTimers': list([ + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ dict({ - 'daysOfTheWeek': list([ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', ]), - 'id': 1, - 'time': '0001-01-01T07:35:00', - 'timerAction': 'DEACTIVATE', }), dict({ - 'daysOfTheWeek': list([ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ 'MONDAY', 'TUESDAY', 'WEDNESDAY', @@ -1311,364 +3585,267 @@ 'SATURDAY', 'SUNDAY', ]), - 'id': 2, - 'time': '0001-01-01T18:00:00', - 'timerAction': 'DEACTIVATE', }), dict({ - 'daysOfTheWeek': list([ - ]), + 'action': 'DEACTIVATE', 'id': 3, - 'time': '0001-01-01T07:00:00', - 'timerAction': 'DEACTIVATE', + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), }), dict({ - 'daysOfTheWeek': list([ - ]), + 'action': 'DEACTIVATE', 'id': 4, - 'time': '0001-01-01T00:00:00', - 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), }), ]), - }), - 'isPreconditionForDepartureActive': False, - }), - 'servicePack': 'TCB1', - }), - 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'state': dict({ - 'chargingProfile': dict({ - 'chargingControlType': 'WEEKLY_PLANNER', - 'chargingMode': 'DELAYED_CHARGING', - 'chargingPreference': 'CHARGING_WINDOW', - 'chargingSettings': dict({ - 'hospitality': 'NO_ACTION', - 'idcc': 'NO_ACTION', - 'targetSoc': 100, - }), - 'climatisationOn': False, - 'departureTimes': list([ - dict({ - 'action': 'DEACTIVATE', - 'id': 1, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ dict({ - 'action': 'DEACTIVATE', - 'id': 2, - 'timeStamp': dict({ - 'hour': 18, - 'minute': 0, + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', 'THURSDAY', - 'FRIDAY', - 'SATURDAY', 'SUNDAY', ]), }), dict({ - 'action': 'DEACTIVATE', - 'id': 3, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 0, + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', 'timerWeekDays': list([ + 'MONDAY', ]), }), dict({ - 'action': 'DEACTIVATE', - 'id': 4, + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ + 'WEDNESDAY', ]), }), ]), - 'reductionOfChargeCurrent': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), - }), - }), - 'checkControlMessages': list([ - ]), - 'climateTimers': list([ - dict({ - 'departureTime': dict({ - 'hour': 6, - 'minute': 40, - }), - 'isWeeklyTimer': True, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'THURSDAY', - 'SUNDAY', - ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, }), - dict({ - 'departureTime': dict({ - 'hour': 12, - 'minute': 50, - }), - 'isWeeklyTimer': False, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'MONDAY', - ]), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', }), - dict({ - 'departureTime': dict({ - 'hour': 18, - 'minute': 59, - }), - 'isWeeklyTimer': True, - 'timerAction': 'DEACTIVATE', - 'timerWeekDays': list([ - 'WEDNESDAY', - ]), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', }), - ]), - 'combustionFuelLevel': dict({ - 'range': 105, - 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, - }), - 'currentMileage': 137009, - 'doorsState': dict({ - 'combinedSecurityState': 'UNLOCKED', - 'combinedState': 'CLOSED', - 'hood': 'CLOSED', - 'leftFront': 'CLOSED', - 'leftRear': 'CLOSED', - 'rightFront': 'CLOSED', - 'rightRear': 'CLOSED', - 'trunk': 'CLOSED', - }), - 'driverPreferences': dict({ - 'lscPrivacyMode': 'OFF', - }), - 'electricChargingState': dict({ - 'chargingConnectionType': 'CONDUCTIVE', - 'chargingLevelPercent': 82, - 'chargingStatus': 'WAITING_FOR_CHARGING', - 'chargingTarget': 100, - 'isChargerConnected': True, - 'range': 174, - }), - 'isLeftSteering': True, - 'isLscSupported': True, - 'lastFetched': '2022-06-22T14:24:23.982Z', - 'lastUpdatedAt': '2022-06-22T13:58:52Z', - 'range': 174, - 'requiredServices': list([ - dict({ - 'dateTime': '2022-10-01T00:00:00.000Z', - 'description': 'Next service due by the specified date.', - 'status': 'OK', - 'type': 'BRAKE_FLUID', + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next vehicle check due after the specified distance or date.', - 'status': 'OK', - 'type': 'VEHICLE_CHECK', + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next state inspection due by the specified date.', - 'status': 'OK', - 'type': 'VEHICLE_TUV', + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - ]), - 'roofState': dict({ - 'roofState': 'CLOSED', - 'roofStateType': 'SUN_ROOF', - }), - 'windowsState': dict({ - 'combinedState': 'CLOSED', - 'leftFront': 'CLOSED', - 'rightFront': 'CLOSED', }), }), - 'vin': '**REDACTED**', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), - 'doors_and_windows': dict({ - 'all_lids_closed': True, - 'all_windows_closed': True, - 'door_lock_state': 'UNLOCKED', - 'lids': list([ - dict({ - 'is_closed': True, - 'name': 'hood', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'leftFront', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'leftRear', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'rightFront', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'rightRear', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'trunk', - 'state': 'CLOSED', - }), - dict({ - 'is_closed': True, - 'name': 'sunRoof', - 'state': 'CLOSED', - }), - ]), - 'open_lids': list([ - ]), - 'open_windows': list([ - ]), - 'windows': list([ - dict({ - 'is_closed': True, - 'name': 'leftFront', - 'state': 'CLOSED', + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), }), - dict({ - 'is_closed': True, - 'name': 'rightFront', - 'state': 'CLOSED', + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, }), - ]), - }), - 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'drive_train_attributes': list([ - 'remaining_range_total', - 'mileage', - 'charging_time_remaining', - 'charging_start_time', - 'charging_end_time', - 'charging_time_label', - 'charging_status', - 'connection_status', - 'remaining_battery_percent', - 'remaining_range_electric', - 'last_charging_end_result', - 'ac_current_limit', - 'charging_target', - 'charging_mode', - 'charging_preferences', - 'is_pre_entry_climatization_enabled', - 'remaining_fuel', - 'remaining_range_fuel', - 'remaining_fuel_percent', - ]), - 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), + 'servicePack': 'TCB1', }), - 'charging_end_time': None, - 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', - 'charging_status': 'WAITING_FOR_CHARGING', - 'charging_target': 100, - 'is_charger_connected': True, - 'remaining_battery_percent': 82, - 'remaining_fuel': list([ - 6, - 'L', - ]), - 'remaining_fuel_percent': 65, - 'remaining_range_electric': list([ - 174, - 'km', - ]), - 'remaining_range_fuel': list([ - 105, - 'km', - ]), - 'remaining_range_total': list([ - 279, - 'km', - ]), - }), - 'has_combustion_drivetrain': True, - 'has_electric_drivetrain': True, - 'is_charging_plan_supported': True, - 'is_lsc_enabled': True, - 'is_remote_charge_start_enabled': False, - 'is_remote_charge_stop_enabled': False, - 'is_remote_climate_start_enabled': True, - 'is_remote_climate_stop_enabled': False, - 'is_remote_horn_enabled': True, - 'is_remote_lights_enabled': True, - 'is_remote_lock_enabled': True, - 'is_remote_sendpoi_enabled': True, - 'is_remote_set_ac_limit_enabled': False, - 'is_remote_set_target_soc_enabled': False, - 'is_remote_unlock_enabled': True, - 'is_vehicle_active': False, - 'is_vehicle_tracking_enabled': False, - 'lsc_type': 'ACTIVATED', - 'mileage': list([ - 137009, - 'km', - ]), - 'name': 'i3 (+ REX)', - 'timestamp': '2022-07-10T09:25:53+00:00', - 'vehicle_location': dict({ - 'account_region': 'row', - 'heading': None, - 'location': None, - 'remote_service_position': None, - 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), - 'vin': '**REDACTED**', + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -1725,98 +3902,98 @@ dict({ 'content': dict({ 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, 'climateFunction': 'AIR_CONDITIONING', 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), 'horn': True, 'isBmwChargingSupported': True, 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, + 'isChargeNowForBusinessSupported': True, 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, 'isDCSContractManagementSupported': True, 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, + 'isEasyChargeEnabled': True, 'isEvGoChargingSupported': False, 'isMiniChargingSupported': False, 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, 'isRemoteEngineStartSupported': False, 'isRemoteHistoryDeletionSupported': False, 'isRemoteHistorySupported': True, 'isRemoteParkingSupported': False, 'isRemoteServicesActivationRequired': False, 'isRemoteServicesBookingRequired': False, - 'isScanAndChargeSupported': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, 'isSustainabilitySupported': False, 'isWifiHotspotServiceSupported': False, 'lastStateCallState': 'ACTIVATED', 'lights': True, 'lock': True, + 'remote360': True, 'remoteChargingCommands': dict({ }), + 'remoteSoftwareUpgrade': True, 'sendPoi': True, 'specialThemeSupport': list([ ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, 'unlock': True, - 'vehicleFinder': False, + 'vehicleFinder': True, 'vehicleStateSource': 'LAST_STATE_CALL', }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', - 'chargingMode': 'DELAYED_CHARGING', - 'chargingPreference': 'CHARGING_WINDOW', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', 'chargingSettings': dict({ + 'acCurrentLimit': 16, 'hospitality': 'NO_ACTION', - 'idcc': 'NO_ACTION', - 'targetSoc': 100, + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, }), - 'climatisationOn': False, 'departureTimes': list([ dict({ 'action': 'DEACTIVATE', 'id': 1, 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, + 'hour': 0, + 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), }), dict({ 'action': 'DEACTIVATE', 'id': 2, 'timeStamp': dict({ - 'hour': 18, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), }), dict({ 'action': 'DEACTIVATE', 'id': 3, 'timeStamp': dict({ - 'hour': 7, + 'hour': 0, 'minute': 0, }), 'timerWeekDays': list([ @@ -1825,67 +4002,61 @@ dict({ 'action': 'DEACTIVATE', 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), 'timerWeekDays': list([ ]), }), ]), - 'reductionOfChargeCurrent': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), - }), }), 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), 'climateTimers': list([ dict({ 'departureTime': dict({ - 'hour': 6, - 'minute': 40, + 'hour': 0, + 'minute': 0, }), - 'isWeeklyTimer': True, - 'timerAction': 'ACTIVATE', + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'THURSDAY', - 'SUNDAY', ]), }), dict({ 'departureTime': dict({ - 'hour': 12, - 'minute': 50, + 'hour': 0, + 'minute': 0, }), - 'isWeeklyTimer': False, - 'timerAction': 'ACTIVATE', + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'MONDAY', ]), }), dict({ 'departureTime': dict({ - 'hour': 18, - 'minute': 59, + 'hour': 0, + 'minute': 0, }), 'isWeeklyTimer': True, 'timerAction': 'DEACTIVATE', 'timerWeekDays': list([ - 'WEDNESDAY', ]), }), ]), 'combustionFuelLevel': dict({ - 'range': 105, - 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), - 'currentMileage': 137009, + 'currentMileage': 1121, 'doorsState': dict({ - 'combinedSecurityState': 'UNLOCKED', + 'combinedSecurityState': 'LOCKED', 'combinedState': 'CLOSED', 'hood': 'CLOSED', 'leftFront': 'CLOSED', @@ -1898,99 +4069,215 @@ 'lscPrivacyMode': 'OFF', }), 'electricChargingState': dict({ - 'chargingConnectionType': 'CONDUCTIVE', - 'chargingLevelPercent': 82, - 'chargingStatus': 'WAITING_FOR_CHARGING', - 'chargingTarget': 100, - 'isChargerConnected': True, - 'range': 174, + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, }), 'isLeftSteering': True, 'isLscSupported': True, - 'lastFetched': '2022-06-22T14:24:23.982Z', - 'lastUpdatedAt': '2022-06-22T13:58:52Z', - 'range': 174, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, 'requiredServices': list([ dict({ - 'dateTime': '2022-10-01T00:00:00.000Z', - 'description': 'Next service due by the specified date.', + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, 'status': 'OK', 'type': 'BRAKE_FLUID', }), dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next vehicle check due after the specified distance or date.', + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, 'status': 'OK', 'type': 'VEHICLE_CHECK', }), dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next state inspection due by the specified date.', 'status': 'OK', - 'type': 'VEHICLE_TUV', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', }), ]), - 'roofState': dict({ - 'roofState': 'CLOSED', - 'roofStateType': 'SUN_ROOF', + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), }), 'windowsState': dict({ 'combinedState': 'CLOSED', 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', }), dict({ 'content': dict({ 'chargeAndClimateSettings': dict({ 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', 'showDepartureTimers': False, }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), }), 'chargeAndClimateTimerDetail': dict({ 'chargingMode': dict({ - 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', }), 'departureTimer': dict({ 'type': 'WEEKLY_DEPARTURE_TIMER', 'weeklyTimers': list([ dict({ 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', ]), 'id': 1, - 'time': '0001-01-01T07:35:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', ]), 'id': 2, - 'time': '0001-01-01T18:00:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ 'daysOfTheWeek': list([ ]), 'id': 3, - 'time': '0001-01-01T07:00:00', + 'time': '0001-01-01T00:00:00', 'timerAction': 'DEACTIVATE', }), dict({ @@ -2004,77 +4291,42 @@ }), 'isPreconditionForDepartureActive': False, }), - 'servicePack': 'TCB1', - }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', - }), - ]), - 'info': dict({ - 'password': '**REDACTED**', - 'refresh_token': '**REDACTED**', - 'region': 'rest_of_world', - 'username': '**REDACTED**', - }), - }) -# --- -# name: test_device_diagnostics_vehicle_not_found - dict({ - 'data': None, - 'fingerprint': list([ - dict({ - 'content': list([ - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), }), - 'vin': '**REDACTED**', + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', }), dict({ 'content': dict({ @@ -2295,7 +4547,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2360,7 +4612,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr new file mode 100644 index 00000000000000..e6902fbacfd591 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'options': list([ + '20', + '25', + '30', + '35', + '40', + '45', + '50', + '55', + '60', + '65', + '70', + '75', + '80', + '85', + '90', + '95', + '100', + ]), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'select.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'icon': 'mdi:current-ac', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'last_changed': , + 'last_updated': , + 'state': '16', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'DELAYED_CHARGING', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py new file mode 100644 index 00000000000000..92daf157a70cfe --- /dev/null +++ b/tests/components/bmw_connected_drive/test_select.py @@ -0,0 +1,84 @@ +"""Test BMW selects.""" +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test select options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("select") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), + ("select.i4_edrive40_ac_charging_limit", "16"), + ("select.i4_edrive40_target_soc", "80"), + ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i4_edrive40_ac_charging_limit", "17"), + ("select.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 From 6f8939025189fe7b712273150f3fca6f0ae275f9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 029/858] Update frontend to 20230330.0 (#90524) --- 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 8c3fb8c1434b70..6a2a904833b617 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1df0c..342942f0dd2965 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6d3d3..3cbd6bd3656f44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 314cd2efdfebcf..9bdb5c485e5d8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 6b0c98045ef79394766b4aa8738410ab1d042d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 030/858] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be01327654c61..3e7f9aa5928f7b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f5cff..efe2a51b83abde 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ def _fetch_migrated_events(): "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ def _fetch_migrated_events(): assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ def _fetch_migrated_states(): "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ def _fetch_migrated_states(): assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From a2efe2445aa5b441a1103b8e92a061b0b469b493 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 031/858] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3e7f9aa5928f7b..23382a9aeb39d8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1445,12 +1445,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1476,7 +1479,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1508,14 +1513,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1543,7 +1551,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index efe2a51b83abde..b75d536d152633 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -998,7 +998,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -1020,7 +1020,7 @@ def _insert_events(): ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1106,3 +1106,149 @@ def _fetch_migrated_states(): assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From 47af325a88db585e96299deced3098ed369e89ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 032/858] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0afb9e6..18a0c2f8f72841 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853e544..b7c0e55745eba7 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efdfe91..0c26d2c7dd5892 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff20fe1..21cebe46f26e98 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7b9af..c33ec16d617fc5 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From 3a3c7389457204d10697fbfd2147034319b6fa38 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 033/858] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a2762..6423723d326dfa 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed0840c..8858ea69590c7c 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f865d..bc5bf6a6d4b717 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd3656f44..8706e4e5f91793 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bdb5c485e5d8d..01b780f4cf7e8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1944,19 +1944,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea18ed4..be53b22be6aaad 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From ed673a1b352add4dba73008c4669c68e86d3a34c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:05:56 -1000 Subject: [PATCH 034/858] Avoid creating a task on callback in owntracks when using mqtt (#90548) Nothing was being awaited in the callback. It did not need to be a coro --- homeassistant/components/owntracks/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd8f7e..560493888d4637 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) From 2e0ecf9bd9a5414683f04d0334c4c2ed35d1ad9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:10:55 -1000 Subject: [PATCH 035/858] Avoid more task creation in the discovery helper (#90552) * Avoid more task creation in the discovery helper There is no longer a reason to awaiti the jobs being dispatched since nothing was using the result and there is no risk of job being garbage collected prematurely anymore since the task revamp * Update homeassistant/helpers/discovery.py --- homeassistant/helpers/discovery.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701a574..b7db5ba69fafd8 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -105,17 +104,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) From d0c38c1e12db38bc62d8286ced4fe1dd14b7ef2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 09:34:17 +0200 Subject: [PATCH 036/858] Move icon constants to entity attributes (#90518) * Move icon constants to attribute * Adjust test --- homeassistant/components/currencylayer/sensor.py | 7 +------ .../components/dublin_bus_transport/sensor.py | 8 ++------ homeassistant/components/fixer/sensor.py | 10 ++-------- homeassistant/components/gitter/sensor.py | 8 ++------ .../homekit_controller/alarm_control_panel.py | 8 +------- .../components/irish_rail_transport/sensor.py | 8 ++------ homeassistant/components/lastfm/sensor.py | 7 +------ homeassistant/components/london_underground/sensor.py | 7 +------ .../components/nederlandse_spoorwegen/sensor.py | 7 +------ homeassistant/components/neurio_energy/sensor.py | 8 ++------ homeassistant/components/numato/sensor.py | 9 ++------- homeassistant/components/oasa_telematics/sensor.py | 8 ++------ homeassistant/components/otp/sensor.py | 7 +------ homeassistant/components/pocketcasts/sensor.py | 8 ++------ homeassistant/components/random/sensor.py | 8 ++------ homeassistant/components/rejseplanen/sensor.py | 8 ++------ homeassistant/components/simulated/sensor.py | 9 ++------- homeassistant/components/smappee/switch.py | 8 ++------ homeassistant/components/srp_energy/const.py | 4 +--- homeassistant/components/srp_energy/sensor.py | 10 ++-------- homeassistant/components/starlingbank/sensor.py | 9 +++------ .../components/swiss_public_transport/sensor.py | 7 +------ homeassistant/components/tmb/sensor.py | 8 +------- homeassistant/components/utility_meter/sensor.py | 7 +------ homeassistant/components/vasttrafik/sensor.py | 7 +------ homeassistant/components/xbox_live/sensor.py | 7 +------ homeassistant/components/yandex_transport/sensor.py | 8 ++------ homeassistant/components/zestimate/sensor.py | 7 +------ tests/components/srp_energy/test_sensor.py | 6 ++---- 29 files changed, 46 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26ae6b..b4a33392894542 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ def name(self): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220ab875..b50bd604763183 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812feb0d..8091f8981e37d3 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ def extra_state_attributes(self): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad5371..db5b189d5eab51 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ def extra_state_attributes(self): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db58fd0..a741cf549202d5 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ def async_add_service(service: Service) -> bool: class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ def get_characteristic_types(self) -> list[str]: CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a23bcb..70b53b80d9c17b 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817bc45e..70f4c22cadeab2 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -26,7 +26,6 @@ CONF_USERS = "users" -ICON = "mdi:radio-fm" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,6 +63,7 @@ class LastfmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" def __init__(self, user, lastfm_api): """Initialize the sensor.""" @@ -127,8 +127,3 @@ def extra_state_attributes(self): def entity_picture(self): """Avatar of the user.""" return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a10939b..8217b3913a8360 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ def native_value(self): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7fac8..f0c782bc1b5825 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ def name(self): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d7225ec2..a9023ffca2b3e5 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ def get_daily_usage(self): class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e0761107d..44adb78e6a0f0b 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cfe6fa..b91096459435e9 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ def extra_state_attributes(self): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f1fe5..7c7c30df970b13 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ def name(self): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c060aee..c541e2cc0f2e0f 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ def native_value(self): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab2f91..d4db30fd61efe4 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ def native_value(self): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff10028f..135205aa95d9c3 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655acc3c1..0f9db48e78c292 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ def native_value(self): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a81f7..828e4a68121780 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ def is_on(self): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b12f1..cbc70786166ed2 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -6,10 +6,8 @@ CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) SENSOR_NAME = "Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e531c7..a919bba1b22fe6 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -17,9 +17,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, MIN_TIME_BETWEEN_UPDATES, SENSOR_NAME, SENSOR_TYPE, @@ -83,7 +81,8 @@ async def async_update_data(): class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False def __init__(self, coordinator): @@ -116,11 +115,6 @@ def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Return icon.""" - return ICON - @property def usage(self): """Return entity state.""" diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d648f..f4a878378783d2 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f892926..12007e1741c41c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ def extra_state_attributes(self): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b76a9..7fe8630cc98321 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ def name(self): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index dad2d8dfaf348c..dd0fb685bac7e4 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -94,7 +94,6 @@ UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -323,6 +322,7 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -659,11 +659,6 @@ def extra_state_attributes(self): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1ba9e..711f66ea0330db 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ def name(self): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index d95031a646e2cc..2ad3f75468c6f2 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -20,7 +20,6 @@ CONF_XUID = "xuid" -ICON = "mdi:microsoft-xbox" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -98,6 +97,7 @@ def get_user_gamercard(api, xuid): class XboxSensor(SensorEntity): """A class for the Xbox account.""" + _attr_icon = "mdi:microsoft-xbox" _attr_should_poll = False def __init__(self, api, xuid, gamercard, interval): @@ -138,11 +138,6 @@ def entity_picture(self): """Avatar of the account.""" return self._picture - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - async def async_added_to_hass(self) -> None: """Start custom polling.""" diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815c2c9..1fbae6c88a6e60 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ def name(self): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186de70..9b520c46819426 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ def extra_state_attributes(self): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 44930886065a91..0f59474ffb15ea 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -3,9 +3,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.srp_energy.const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, SENSOR_NAME, SENSOR_TYPE, SRP_ENERGY_DOMAIN, @@ -91,10 +89,10 @@ async def test_srp_entity(hass: HomeAssistant) -> None: assert srp_entity.unique_id == SENSOR_TYPE assert srp_entity.state is None assert srp_entity.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR - assert srp_entity.icon == ICON + assert srp_entity.icon == "mdi:flash" assert srp_entity.usage == "2.00" assert srp_entity.should_poll is False - assert srp_entity.attribution == ATTRIBUTION + assert srp_entity.attribution == "Powered by SRP Energy" assert srp_entity.available is not None assert srp_entity.device_class is SensorDeviceClass.ENERGY assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING From b3887a633d2f57458687c2fee3503f0656d0205f Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 09:44:30 +0200 Subject: [PATCH 037/858] Bump PyVicare to 2.25.0 (#90536) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131fdfd..ae578492a1ed0e 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f91793..2edc33de9bfbf0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -50,7 +50,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01b780f4cf7e8c..1a29f26409dec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -46,7 +46,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From b9f0701336221569a503f593d0a25a5eb35bfae1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 11:43:28 +0200 Subject: [PATCH 038/858] Update ruff to v0.0.260 (#90566) --- .pre-commit-config.yaml | 2 +- homeassistant/components/mysensors/light.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db3b4d..ffdd8904af3647 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.260 hooks: - id: ruff args: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 68f8bb566f1d8f..213e268696e90c 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -172,7 +172,7 @@ def _turn_on_rgb(self, **kwargs: Any) -> None: new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ def _turn_on_rgbw(self, **kwargs: Any) -> None: new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a1faadfea4ac0e..c46ed5e4e2811c 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,5 +4,5 @@ bandit==1.7.4 black==23.1.0 codespell==2.2.2 isort==5.12.0 -ruff==0.0.256 +ruff==0.0.260 yamllint==1.28.0 From b24a5750c39b6f36dee0027dad027dde586615df Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:19:45 +0200 Subject: [PATCH 039/858] Add CI timeout to codecov job (#90572) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8464b1a299f51e..03a6f2da925da3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1091,6 +1091,7 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub uses: actions/checkout@v3.5.0 From 6bad5f02c6b2cd1edf37129330c58ebad6a59546 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:20:08 +0200 Subject: [PATCH 040/858] Update black to 23.3.0 (#90569) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffdd8904af3647..88bb4a703a95de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c46ed5e4e2811c..d76382c5c99b72 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==23.1.0 +black==23.3.0 codespell==2.2.2 isort==5.12.0 ruff==0.0.260 From c7e8fc9f9d365a395ae2bbc5e4e83ca50a2978fd Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 31 Mar 2023 12:38:23 +0200 Subject: [PATCH 041/858] Use more meaningful states for snapcast groups and clients (#77449) * Show muted snapcast groups as idle and use playing/idle state instead of on state for clients * New module constant STREAM_STATUS * Fix return type hint in snapcast --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/snapcast/media_player.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 6f965155bba4df..4fd7c587d40c37 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -42,6 +42,12 @@ {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} + def register_services(): """Register snapcast services.""" @@ -157,11 +163,9 @@ async def async_will_remove_from_hass(self) -> None: @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -289,11 +293,13 @@ def source_list(self): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): From 6153f17155f0d54a1bb5c4e87123e305042851d4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:58:53 +0200 Subject: [PATCH 042/858] Update sentry-sdk to 1.18.0 (#90571) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a552ce..066549e2629d37 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.16.0"] + "requirements": ["sentry-sdk==1.18.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2edc33de9bfbf0..2aab38d76a8d36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a29f26409dec5..7779f3a5f7b52c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1661,7 +1661,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 28736e2ce430e3d6d40ad21848e9fd3a3768ac40 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:59:49 +0200 Subject: [PATCH 043/858] Update orjson to 3.8.9 (#90570) --- 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 342942f0dd2965..a7815bdf912278 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ ifaddr==0.1.7 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.8.7 +orjson==3.8.9 paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 diff --git a/pyproject.toml b/pyproject.toml index d409ef188d11be..fbdee78422bc18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.1", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.7", + "orjson==3.8.9", "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 84726cb49d9e05..2dd936f7068a82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.6.0 cryptography==40.0.1 pyOpenSSL==23.1.0 -orjson==3.8.7 +orjson==3.8.9 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 From 2e26b6e0ccb2cbf059aefcb5cf1f4f449ad66187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timm=20Sch=C3=A4uble?= Date: Fri, 31 Mar 2023 14:10:12 +0200 Subject: [PATCH 044/858] Add attachments to simplepush (#81033) * Add attachments * Fix looking for attachment keywords in values * Improve attachment input format * Implement better approach to attachment parsing * Make ruff happy * Adjust attachment format and implementation according to comment from emontnemery --- homeassistant/components/simplepush/const.py | 1 + homeassistant/components/simplepush/notify.py | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d98cc..101e7cb35fd773 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680ee8b..3e7fad8863f8d3 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ def send_message(self, message: str, **kwargs: Any) -> None: """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ def send_message(self, message: str, **kwargs: Any) -> None: salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") From ab699d17a50acf14ca0183e7cf5a5ecbd4572a5f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:12:51 +0200 Subject: [PATCH 045/858] Ensure numeric sensors have a valid value (#85605) * Ensure numeric sensors have a valid value * Flake8 --- homeassistant/components/sensor/__init__.py | 37 +++++---------------- tests/components/sensor/test_init.py | 16 ++++----- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4f56be77a94470..d0fdc8a0886445 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ def unit_of_measurement(self) -> str | None: @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ def state(self) -> Any: # noqa: C901 else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" + ) from err else: numerical_value = value diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 8be15f1c7cd1df..82ea25b5a11c2d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1803,20 +1803,20 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - ("native_value", "expected"), + "native_value", [ - ("abc", "abc"), - ("13.7.1", "13.7.1"), - (datetime(2012, 11, 10, 7, 35, 1), "2012-11-10 07:35:01"), - (date(2012, 11, 10), "2012-11-10"), + "", + "abc", + "13.7.1", + datetime(2012, 11, 10, 7, 35, 1), + date(2012, 11, 10), ], ) -async def test_non_numeric_validation_warn( +async def test_non_numeric_validation_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, - expected: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1837,7 +1837,7 @@ async def test_non_numeric_validation_warn( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == expected + assert state is None assert ( "thus indicating it has a numeric value; " From a616ac2b60c85ebf6d4416e31c0530ed2217e120 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:25:49 +0200 Subject: [PATCH 046/858] Move attribution constants to entity attributes (#90519) * Move attribution constants to entity attributes * Adjust meteo france * Adjust meteoclimatic * Adjust nws --- homeassistant/components/ampio/air_quality.py | 9 +++------ homeassistant/components/ampio/const.py | 1 - homeassistant/components/met/weather.py | 12 ++++-------- homeassistant/components/met_eireann/const.py | 2 -- .../components/met_eireann/weather.py | 8 ++------ .../components/meteo_france/weather.py | 6 +----- .../components/meteoclimatic/weather.py | 6 +----- homeassistant/components/nilu/air_quality.py | 8 ++------ .../components/norway_air/air_quality.py | 18 +++++++----------- homeassistant/components/nws/weather.py | 6 +----- .../components/opensensemap/air_quality.py | 8 ++------ 11 files changed, 23 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b454b..a423a628367032 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ def particulate_matter_10(self) -> str | None: """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff416a3..b1a13ce9414aaf 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf32abb..a6dcb23cc475c6 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ def wind_bearing(self) -> float | str | None: ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d17943..1cab9c9099f16f 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa7da2..cce35731c728d1 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ def wind_bearing(self): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bbef86..e1a530eef97d00 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ def forecast(self): } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d02a0..11346ab18f9e9b 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ def native_wind_speed(self): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c594609e2..3745c6bae6f755 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ def update(self): class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc99ea..1a3d3661a1591b 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def _decorator(self): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ def __init__(self, name, coordinates, forecast, session): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e82b8..9edf6e61751b3a 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ class NWSForecast(Forecast): class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ def _update_callback(self) -> None: self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb91580b59..0e918103cd286a 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ def particulate_matter_10(self): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() From 8cbe3940283043f583437d0994104fa9c5a0d77d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 31 Mar 2023 15:27:37 +0300 Subject: [PATCH 047/858] Use `get_ha_sensor_data` method to update glances sensors (#83983) * Use `get_ha_sensor_data` method to update sensor state * update tests * Use `get_ha_sensor_data` to validate connection * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- .../components/glances/config_flow.py | 2 +- .../components/glances/coordinator.py | 3 +- homeassistant/components/glances/sensor.py | 231 ++++-------------- tests/components/glances/__init__.py | 97 +++++++- tests/components/glances/conftest.py | 7 +- tests/components/glances/test_config_flow.py | 2 +- tests/components/glances/test_init.py | 2 +- tests/components/glances/test_sensor.py | 69 ++++++ 8 files changed, 224 insertions(+), 189 deletions(-) create mode 100644 tests/components/glances/test_sensor.py diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a913cb5..04e133248a6399 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6e108..01e498a88979af 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> Non async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a206685..8b836fba3eaa38 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ class GlancesSensorEntityDescription( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ class GlancesSensorEntityDescription( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ class GlancesSensorEntityDescription( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ class GlancesSensorEntityDescription( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ class GlancesSensorEntityDescription( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ class GlancesSensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ class GlancesSensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ class GlancesSensorEntityDescription( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ class GlancesSensorEntityDescription( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ class GlancesSensorEntityDescription( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ class GlancesSensorEntityDescription( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ def _migrate_old_unique_ids( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) - ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ def __init__( self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 4818e9258de438..8c9394ae84f765 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,6 +1,8 @@ """Tests for Glances.""" -MOCK_USER_INPUT = { +from typing import Any + +MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", @@ -30,6 +32,85 @@ "key": "disk_name", }, ], + "docker": { + "containers": [ + { + "key": "name", + "name": "container1", + "Status": "running", + "cpu": {"total": 50.94973493230174}, + "cpu_percent": 50.94973493230174, + "memory": { + "usage": 1120321536, + "limit": 3976318976, + "rss": 480641024, + "cache": 580915200, + "max_usage": 1309597696, + }, + "memory_usage": 539406336, + }, + { + "key": "name", + "name": "container2", + "Status": "running", + "cpu": {"total": 26.23567931034483}, + "cpu_percent": 26.23567931034483, + "memory": { + "usage": 85139456, + "limit": 3976318976, + "rss": 33677312, + "cache": 35012608, + "max_usage": 87650304, + }, + "memory_usage": 50126848, + }, + ] + }, + "fs": [ + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/ssl", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/media", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + ], + "mem": { + "total": 3976318976, + "available": 2878337024, + "percent": 27.6, + "used": 1097981952, + "free": 2878337024, + "active": 567971840, + "inactive": 1679704064, + "buffers": 149807104, + "cached": 1334816768, + "shared": 1499136, + }, + "sensors": [ + { + "label": "cpu_thermal 1", + "value": 59, + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_core", + "key": "label", + } + ], "system": { "os_name": "Linux", "hostname": "fedora-35", @@ -40,3 +121,17 @@ }, "uptime": "3 days, 10:25:20", } + +HA_SENSOR_DATA: dict[str, Any] = { + "fs": { + "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + "/media": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + }, + "sensors": {"cpu_thermal 1": {"temperature_core": 59}}, + "mem": { + "memory_use_percent": 27.6, + "memory_use": 1047.1, + "memory_free": 2745.0, + }, + "docker": {"docker_active": 2, "docker_cpu_use": 77.2, "docker_memory_use": 1149.6}, +} diff --git a/tests/components/glances/conftest.py b/tests/components/glances/conftest.py index d92d3cc33d4f45..9f4590ab5e0173 100644 --- a/tests/components/glances/conftest.py +++ b/tests/components/glances/conftest.py @@ -3,13 +3,14 @@ import pytest -from . import MOCK_DATA +from . import HA_SENSOR_DATA @pytest.fixture(autouse=True) def mock_api(): """Mock glances api.""" with patch("homeassistant.components.glances.Glances") as mock_api: - mock_api.return_value.get_data = AsyncMock(return_value=None) - mock_api.return_value.data.return_value = MOCK_DATA + mock_api.return_value.get_ha_sensor_data = AsyncMock( + return_value=HA_SENSOR_DATA + ) yield mock_api diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index ab6420550593f5..187e319fe08a96 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -42,7 +42,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test to return error if we cannot connect.""" - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 944d9d55ae211a..546f57ac3d9258 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -29,7 +29,7 @@ async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py new file mode 100644 index 00000000000000..e5aadc92156bdd --- /dev/null +++ b/tests/components/glances/test_sensor.py @@ -0,0 +1,69 @@ +"""Tests for glances sensors.""" +import pytest + +from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HA_SENSOR_DATA, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_sensor_states(hass: HomeAssistant) -> None: + """Test sensor states are correctly collected from library.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): + assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + + if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): + assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id", "new_unique_id"), + [ + ( + "glances_ssl_used_percent", + "0.0.0.0-Glances /ssl used percent", + "/ssl-disk_use_percent", + ), + ( + "glances_cpu_thermal_1_temperature", + "0.0.0.0-Glances cpu_thermal 1 Temperature", + "cpu_thermal 1-temperature_core", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str +): + """Test unique id migration.""" + old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} + entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" From ea32cc5d9213e2a78cad302dd3102469a59de36b Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 14:33:58 +0200 Subject: [PATCH 048/858] Refactor vicare config_flow tests (#90568) * Refactor vicare config_flow tests * Address review comments * Remove unused parameters --- tests/components/vicare/__init__.py | 12 +- tests/components/vicare/conftest.py | 16 ++ .../vicare/snapshots/test_config_flow.ambr | 17 ++ tests/components/vicare/test_config_flow.py | 147 ++++++++---------- 4 files changed, 103 insertions(+), 89 deletions(-) create mode 100644 tests/components/vicare/conftest.py create mode 100644 tests/components/vicare/snapshots/test_config_flow.ambr diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index 66cbfdc1d26362..9e59c529408717 100644 --- a/tests/components/vicare/__init__.py +++ b/tests/components/vicare/__init__.py @@ -1,16 +1,6 @@ """Test for ViCare.""" from __future__ import annotations -from typing import Final - -from homeassistant.components.vicare.const import CONF_HEATING_TYPE -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME - -ENTRY_CONFIG: Final[dict[str, str]] = { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - CONF_HEATING_TYPE: "auto", -} +MODULE = "homeassistant.components.vicare" MOCK_MAC = "B874241B7B9" diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py new file mode 100644 index 00000000000000..2ecd4f4309a7aa --- /dev/null +++ b/tests/components/vicare/conftest.py @@ -0,0 +1,16 @@ +"""Fixtures for ViCare integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from . import MODULE + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/vicare/snapshots/test_config_flow.ambr b/tests/components/vicare/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000000..e99eda8234e808 --- /dev/null +++ b/tests/components/vicare/snapshots/test_config_flow.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_form_dhcp + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- +# name: test_user_create_entry + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 10b7861ef78e8c..72fb8d0d0b63b1 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -1,132 +1,123 @@ """Test the ViCare config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import ENTRY_CONFIG, MOCK_MAC +from . import MOCK_MAC, MODULE from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert len(result["errors"]) == 0 +VALID_CONFIG = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", +} - with patch( - "homeassistant.components.vicare.config_flow.vicare_login", - return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 +DHCP_INFO = dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, +) -async def test_invalid_login(hass: HomeAssistant) -> None: - """Test a flow with an invalid Vicare login.""" +async def test_user_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + # test PyViCareInvalidCredentialsError with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", side_effect=PyViCareInvalidCredentialsError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() -async def test_form_dhcp(hass: HomeAssistant) -> None: +async def test_form_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: """Test we can setup from dhcp.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() async def test_dhcp_single_instance_allowed(hass: HomeAssistant) -> None: """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -135,12 +126,12 @@ async def test_user_input_single_instance_allowed(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="ViCare", - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" From 4f54e33f670e33e618c443c6a5f76885d38b9db8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 049/858] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010c304..3ca13e56b299ed 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848f8d6..857d53eb5276ff 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844e435..026daeea38c65e 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed840b..9c6c5e0b4de366 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From 23372e8bc4629a1912e7615514b4f0d4a3e04982 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 050/858] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81cfb6..9615404a9a6ce5 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ async def _async_set_arm_state( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 1ca7f0dc6a6db63586d9fdffebc8cfc582d10c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 051/858] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6cf3f..c2d1a2155c3a15 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", From 3467f4674e02630cd6f20fa9055a962a35f378fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:53:35 +0200 Subject: [PATCH 052/858] Remove unnecessary calls to `async_update_entry` from `async_migrate_entry` (#90575) --- homeassistant/components/airvisual/__init__.py | 1 - homeassistant/components/ambient_station/__init__.py | 1 - homeassistant/components/axis/__init__.py | 1 - homeassistant/components/landisgyr_heat_meter/__init__.py | 1 - homeassistant/components/samsungtv/__init__.py | 1 - homeassistant/components/velbus/__init__.py | 2 -- 6 files changed, 7 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b7879270abe..21be2e5d664a6b 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2fd0d0..f68ae3df1144ac 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c515472..65a425fa5c49fa 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd41fa6..0279af2e610bed 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ def update_entity_unique_id(entity_entry): await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76ba9d..3406185b966d7a 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -340,7 +340,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 554b16877c723f..b2b1cb316244d7 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -210,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True From 9a17c437ad7a94f71311670e6688f820f5d2ac39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:59:48 +0200 Subject: [PATCH 053/858] Remove some dead code from google_assistant (#90581) --- homeassistant/components/google_assistant/trait.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac221b9..3752574f31f138 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, From 8e77d215e7b6572cca86e8d554a02bf8406ebe87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 054/858] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c413092..fd6adb009aaefc 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ def code_format(self) -> alarm.CodeFormat | None: async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ def _async_update_state(self, state: str) -> None: def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad1056972..549fa995179eb7 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 469321157d67ce8afa8564a0b139a5a77f44034a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 055/858] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d698ad..da77aea6c4af86 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ def code_format(self) -> alarm.CodeFormat | None: async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ def _async_set_state_update_events(self) -> None: def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e688d..f1a4b2da2ef6b4 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 149e610bca452b20f099ac790473aee2175be00c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 17:03:02 +0200 Subject: [PATCH 056/858] Drop __eq__ dunder method from Entity (#90585) --- homeassistant/helpers/entity.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a83d5..eaac35286d9920 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -945,25 +945,6 @@ async def _async_registry_updated(self, event: Event) -> None: self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" From c566303edbfd2180d3c068aa776e65d8136eb21b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 06:23:05 -1000 Subject: [PATCH 057/858] Avoid writing state to all esphome entities at shutdown (#90555) --- homeassistant/components/esphome/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 192a19e480be17..58659a671f0702 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -349,7 +349,12 @@ async def on_disconnect() -> None: # the next state update of that type when the device reconnects for state_keys in entry_data.state.values(): state_keys.clear() - entry_data.async_update_device_state(hass) + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" From 03137feba5ef13772672eab51934c0bf21e74461 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 058/858] Update frontend to 20230331.0 (#90594) --- 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 6a2a904833b617..114760923eb81c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7815bdf912278..ab00d9ca8181fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 2aab38d76a8d36..2906cda9a4da74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7779f3a5f7b52c..c1966555f727e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 611d4135fd8666ee3a0c8da773eb0acf66480fcd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 20:19:58 +0200 Subject: [PATCH 059/858] Add ComponentProtocol to improve type checking (#90586) --- homeassistant/config.py | 4 +- homeassistant/config_entries.py | 10 ++-- homeassistant/loader.py | 70 ++++++++++++++++++++--- homeassistant/setup.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 8 +++ 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2b1e4..0a5da91d9421a7 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 454cfeade27776..3731f5fa9aeaca 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -383,7 +383,7 @@ async def async_setup( result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -546,8 +546,7 @@ async def async_unload( await self._async_process_on_unload() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +627,14 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 600fef5a13482a..11f551b37ebee5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -15,13 +15,14 @@ import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, AwesomeVersionException, AwesomeVersionStrategy, ) +import voluptuous as vol from . import generated from .generated.application_credentials import APPLICATION_CREDENTIALS @@ -35,7 +36,10 @@ # Typing imports that create a circular dependency if TYPE_CHECKING: + from .config_entries import ConfigEntry from .core import HomeAssistant + from .helpers import device_registry as dr + from .helpers.typing import ConfigType _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -260,6 +264,52 @@ async def async_get_config_flows( return flows +class ComponentProtocol(Protocol): + """Define the format of an integration.""" + + CONFIG_SCHEMA: vol.Schema + DOMAIN: str + + async def async_setup_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up a config entry.""" + + async def async_unload_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload a config entry.""" + + async def async_migrate_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Migrate an old config entry.""" + + async def async_remove_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Remove a config entry.""" + + async def async_remove_config_entry_device( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, + ) -> bool: + """Remove a config entry device.""" + + async def async_reset_platform( + self, hass: HomeAssistant, integration_name: str + ) -> None: + """Release resources.""" + + async def async_setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + def setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + async def async_get_integration_descriptions( hass: HomeAssistant, ) -> dict[str, Any]: @@ -750,14 +800,18 @@ async def resolve_dependencies(self) -> bool: return self._all_dependencies_resolved - def get_component(self) -> ModuleType: + def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( + DATA_COMPONENTS, {} + ) if self.domain in cache: return cache[self.domain] try: - cache[self.domain] = importlib.import_module(self.pkg_path) + cache[self.domain] = cast( + ComponentProtocol, importlib.import_module(self.pkg_path) + ) except ImportError: raise except Exception as err: @@ -922,7 +976,7 @@ def __init__(self, from_domain: str, to_domain: str) -> None: def _load_file( hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ModuleType | None: +) -> ComponentProtocol | None: """Try to load specified file. Looks in config dir first, then built-in components. @@ -957,7 +1011,7 @@ def _load_file( cache[comp_or_platform] = module - return module + return cast(ComponentProtocol, module) except ImportError as err: # This error happens if for example custom_components/switch @@ -981,7 +1035,7 @@ def _load_file( class ModuleWrapper: """Class to wrap a Python module and auto fill in hass argument.""" - def __init__(self, hass: HomeAssistant, module: ModuleType) -> None: + def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: """Initialize the module wrapper.""" self._hass = hass self._module = module @@ -1010,7 +1064,7 @@ def __getattr__(self, comp_name: str) -> ModuleWrapper: integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) if isinstance(integration, Integration): - component: ModuleType | None = integration.get_component() + component: ComponentProtocol | None = integration.get_component() else: # Fallback to importing old-school component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index ce502116cf2c2c..f217aa297e5ae8 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -236,7 +236,7 @@ def log_error(msg: str) -> None: SLOW_SETUP_WARNING, ) - task = None + task: Awaitable[bool] | None = None result: Any | bool = True try: if hasattr(component, "async_setup"): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a84d578cf525e8..b917ba2f3c7f42 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -202,6 +202,14 @@ class ClassTypeHintMatch: }, return_type="bool", ), + TypeHintMatch( + function_name="async_reset_platform", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=None, + ), ], "__any_platform__": [ TypeHintMatch( From 8256d9b47224044e0ae67e42872b030e15b00b67 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Mar 2023 20:30:04 +0200 Subject: [PATCH 060/858] Remove xbox_live integration (#90592) --- .coveragerc | 1 - CODEOWNERS | 1 - homeassistant/brands/microsoft.json | 3 +- .../components/xbox_live/__init__.py | 1 - .../components/xbox_live/manifest.json | 9 - homeassistant/components/xbox_live/sensor.py | 156 ------------------ .../components/xbox_live/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 9 files changed, 1 insertion(+), 187 deletions(-) delete mode 100644 homeassistant/components/xbox_live/__init__.py delete mode 100644 homeassistant/components/xbox_live/manifest.json delete mode 100644 homeassistant/components/xbox_live/sensor.py delete mode 100644 homeassistant/components/xbox_live/strings.json diff --git a/.coveragerc b/.coveragerc index d313da55dd80f3..87886b84120db2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1438,7 +1438,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 0e918caadeaf93..88df4edd6fc459 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1367,7 +1367,6 @@ build.json @home-assistant/supervisor /tests/components/ws66i/ @ssaenger /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a6f3a..9da24e76f1997a 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac3518ae3..00000000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da05a99..00000000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index 2ad3f75468c6f2..00000000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_icon = "mdi:microsoft-xbox" - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd7bb0..00000000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02273b8d97f88a..9e5155f0cb8edc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3293,12 +3293,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 2906cda9a4da74..9a6aaec2986b1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2643,9 +2643,6 @@ wolf_smartset==0.1.11 # homeassistant.components.xbox xbox-webapi==2.0.11 -# homeassistant.components.xbox_live -xboxapi==2.0.1 - # homeassistant.components.xiaomi_ble xiaomi-ble==0.16.4 From 09d54428c980f21f5e477241ef7be96a126bfe7b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 061/858] Bump reolink-aio to 0.5.9 (#90590) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571de1d..b8de6cd83991f4 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a6aaec2986b1a..cf5ca371c7542c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1966555f727e7..edb2c970bcd068 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From 44eaf70625948b0e5fb5f77df7b953c6c2c3b7fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 062/858] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c2574..638ede722f5cb4 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ def speaker_activity(self, source: str) -> None: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 8018be28eef93b3f3cb93092f27bef8af94e2896 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:34:42 -0400 Subject: [PATCH 063/858] TTS: allow resolving engine and test supported options (#90539) TTS: allow resolving engine --- homeassistant/components/tts/__init__.py | 38 ++++++++++++++++++++ homeassistant/components/tts/media_source.py | 12 +++---- tests/components/tts/test_init.py | 24 +++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index aa8864ad23da04..119a013ebf6947 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -136,6 +136,44 @@ class TTSCache(TypedDict): voice: bytes +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + manager: SpeechManager = hass.data[DOMAIN] + + if engine is not None: + if engine not in manager.providers: + return None + return engine + + if not manager.providers: + return None + + if "cloud" in manager.providers: + return "cloud" + + return next(iter(manager.providers)) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + manager: SpeechManager = hass.data[DOMAIN] + try: + manager.process_options(engine, language, options) + except HomeAssistantError: + return False + + return True + + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11ec0f..f52292e8096749 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,16 +40,12 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" + from . import async_resolve_engine # pylint: disable=import-outside-toplevel + manager: SpeechManager = hass.data[DOMAIN] - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") manager.process_options(engine, language, options) params = { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 251ed9b30c0f8c..694c9ff676c975 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,6 +1,7 @@ """The tests for the TTS component.""" from http import HTTPStatus from typing import Any +from unittest.mock import patch import pytest import voluptuous as vol @@ -972,3 +973,26 @@ async def test_generate_media_source_id_invalid_options( """Test generating a media source ID.""" with pytest.raises(HomeAssistantError): tts.generate_media_source_id(hass, "msg", engine, language, options, None) + + +def test_resolve_engine(hass: HomeAssistant, setup_tts) -> None: + """Test resolving engine.""" + assert tts.async_resolve_engine(hass, None) == "test" + assert tts.async_resolve_engine(hass, "test") == "test" + assert tts.async_resolve_engine(hass, "non-existing") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {}, clear=True): + assert tts.async_resolve_engine(hass, "test") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {"cloud": object()}): + assert tts.async_resolve_engine(hass, None) == "cloud" + + +async def test_support_options(hass: HomeAssistant, setup_tts) -> None: + """Test supporting options.""" + assert await tts.async_support_options(hass, "test", "en") is True + assert await tts.async_support_options(hass, "test", "nl") is False + assert ( + await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"}) + is False + ) From ad26317b75df41eefba857691d1b4a0ae135ce79 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:36:39 -0400 Subject: [PATCH 064/858] Conversation: allow getting agent info (#90540) * Conversation: allow getting agent info * Add unset agenet back --- .../components/conversation/__init__.py | 43 +++++++++++++++++-- .../conversation/snapshots/test_init.ambr | 34 +++++++++++++++ tests/components/conversation/test_init.py | 28 ++++++++---- 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 tests/components/conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652ab45..5009530dc311ad 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,7 @@ import asyncio import logging import re -from typing import Any +from typing import Any, TypedDict import voluptuous as vol @@ -20,6 +20,15 @@ from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .default_agent import DefaultAgent +__all__ = [ + "DOMAIN", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] + _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" @@ -270,6 +279,31 @@ async def post(self, request, data): return self.json(result.as_dict()) +class AgentInfo(TypedDict): + """Dictionary holding agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info["id"] == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -332,12 +366,15 @@ async def async_get_agent( return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ + agents: list[AgentInfo] = [ { "id": AgentManager.HOME_ASSISTANT_AGENT, "name": "Home Assistant", diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000000..1547b5b5e88832 --- /dev/null +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_get_agent_info + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_info.1 + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }) +# --- +# name: test_get_agent_info.2 + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_list + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }), + ]), + 'default_agent': 'mock-entry', + }) +# --- diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 55a345bd605c62..eb38d875bfa7cf 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -929,7 +930,11 @@ async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: async def test_get_agent_list( - hass: HomeAssistant, init_components, mock_agent, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + init_components, + mock_agent, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test getting agent info.""" client = await hass_ws_client(hass) @@ -940,10 +945,17 @@ async def test_get_agent_list( assert msg["id"] == 5 assert msg["type"] == "result" assert msg["success"] - assert msg["result"] == { - "agents": [ - {"id": "homeassistant", "name": "Home Assistant"}, - {"id": "mock-entry", "name": "Mock Title"}, - ], - "default_agent": "mock-entry", - } + assert msg["result"] == snapshot + + +async def test_get_agent_info( + hass: HomeAssistant, init_components, mock_agent, snapshot: SnapshotAssertion +) -> None: + """Test get agent info.""" + agent_info = conversation.async_get_agent_info(hass) + # Test it's the default + assert agent_info["id"] == mock_agent.agent_id + assert agent_info == snapshot + assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot + assert conversation.async_get_agent_info(hass, "not exist") is None From 84eb9c5f97fffdbb5312fe406e48ea19571e35e0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 065/858] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd63063f..6d4899be37c63d 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 00000000000000..fbb25f1cbd3415 --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 01a05340c69cb3f5a6159e520b19bef65a13c5e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:04:22 -0400 Subject: [PATCH 066/858] Voice Assistant: improve error handling (#90541) Co-authored-by: Michael Hansen --- homeassistant/components/stt/__init__.py | 16 +- .../components/voice_assistant/pipeline.py | 169 ++++++++++++------ .../voice_assistant/websocket_api.py | 53 +++--- .../snapshots/test_websocket.ambr | 25 ++- .../voice_assistant/test_websocket.py | 29 +-- 5 files changed, 178 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 631994021942e4..b858cc743a2d18 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -36,12 +36,20 @@ @callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) + if domain: + return hass.data[DOMAIN].get(domain) - return hass.data[DOMAIN][domain] + if not hass.data[DOMAIN]: + return None + + if "cloud" in hass.data[DOMAIN]: + return hass.data[DOMAIN]["cloud"] + + return next(iter(hass.data[DOMAIN].values())) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 806a603f5e5015..ef13d54e6a1781 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation, media_source, stt +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -17,8 +17,6 @@ from .const import DOMAIN -DEFAULT_TIMEOUT = 30 # seconds - _LOGGER = logging.getLogger(__name__) @@ -151,6 +149,9 @@ class PipelineRun: event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] runner_data: Any | None = None + stt_provider: stt.Provider | None = None + intent_agent: str | None = None + tts_engine: str | None = None def __post_init__(self): """Set language for pipeline.""" @@ -181,13 +182,39 @@ def end(self): ) ) + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + + if stt_provider is None: + engine = self.pipeline.stt_engine or "default" + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {engine} does not support input speech " + "to text metadata" + ), + ) + + self.stt_provider = stt_provider + async def speech_to_text( self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: """Run speech to text portion of pipeline. Returns the spoken text.""" - engine = self.pipeline.stt_engine or "default" + if self.stt_provider is None: + raise RuntimeError("Speech to text was not prepared") + + engine = self.stt_provider.name + self.event_callback( PipelineEvent( PipelineEventType.STT_START, @@ -198,28 +225,11 @@ async def speech_to_text( ) ) - try: - # Load provider - stt_provider: stt.Provider = stt.async_get_provider( - self.hass, self.pipeline.stt_engine - ) - assert stt_provider is not None - except Exception as src_error: - _LOGGER.exception("No speech to text provider for %s", engine) - raise SpeechToTextError( - code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", - ) from src_error - - if not stt_provider.check_metadata(metadata): - raise SpeechToTextError( - code="stt-provider-unsupported-metadata", - message=f"Provider {engine} does not support input speech to text metadata", - ) - try: # Transcribe audio stream - result = await stt_provider.async_process_audio_stream(metadata, stream) + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech to text") raise SpeechToTextError( @@ -253,15 +263,33 @@ async def speech_to_text( return result.text + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, self.pipeline.conversation_engine + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info["id"] + async def recognize_intent( self, intent_input: str, conversation_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.pipeline.conversation_engine or "default", + "engine": self.intent_agent, "intent_input": intent_input, }, ) @@ -274,7 +302,7 @@ async def recognize_intent( conversation_id=conversation_id, context=self.context, language=self.language, - agent_id=self.pipeline.conversation_engine, + agent_id=self.intent_agent, ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") @@ -296,13 +324,38 @@ async def recognize_intent( return speech + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = tts.async_resolve_engine(self.hass, self.pipeline.tts_engine) + + if engine is None: + engine = self.pipeline.tts_engine or "default" + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) + + if not await tts.async_support_options(self.hass, engine, self.language): + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.language}" + ), + ) + + self.tts_engine = engine + async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + if self.tts_engine is None: + raise RuntimeError("Text to speech was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.pipeline.tts_engine or "default", + "engine": self.tts_engine, "tts_input": tts_input, }, ) @@ -315,7 +368,8 @@ async def text_to_speech(self, tts_input: str) -> str: tts_generate_media_source_id( self.hass, tts_input, - engine=self.pipeline.tts_engine, + engine=self.tts_engine, + language=self.language, ), ) except Exception as src_error: @@ -341,6 +395,8 @@ async def text_to_speech(self, tts_input: str) -> str: class PipelineInput: """Input to a pipeline run.""" + run: PipelineRun + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -355,21 +411,10 @@ class PipelineInput: conversation_id: str | None = None - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - async def _execute(self, run: PipelineRun): - self._validate(run.start_stage) - - # stt -> intent -> tts - run.start() - current_stage = run.start_stage + async def execute(self): + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage try: # Speech to text @@ -377,29 +422,29 @@ async def _execute(self, run: PipelineRun): if current_stage == PipelineStage.STT: assert self.stt_metadata is not None assert self.stt_stream is not None - intent_input = await run.speech_to_text( + intent_input = await self.run.speech_to_text( self.stt_metadata, self.stt_stream, ) current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.STT: + if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: assert intent_input is not None - tts_input = await run.recognize_intent( + tts_input = await self.run.recognize_intent( intent_input, self.conversation_id ) current_stage = PipelineStage.TTS - if run.end_stage != PipelineStage.INTENT: + if self.run.end_stage != PipelineStage.INTENT: if current_stage == PipelineStage.TTS: assert tts_input is not None - await run.text_to_speech(tts_input) + await self.run.text_to_speech(tts_input) except PipelineError as err: - run.event_callback( + self.run.event_callback( PipelineEvent( PipelineEventType.ERROR, {"code": err.code, "message": err.message}, @@ -407,11 +452,11 @@ async def _execute(self, run: PipelineRun): ) return - run.end() + self.run.end() - def _validate(self, stage: PipelineStage): + async def validate(self): """Validate pipeline input against start stage.""" - if stage == PipelineStage.STT: + if self.run.start_stage == PipelineStage.STT: if self.stt_metadata is None: raise PipelineRunValidationError( "stt_metadata is required for speech to text" @@ -421,13 +466,29 @@ def _validate(self, stage: PipelineStage): raise PipelineRunValidationError( "stt_stream is required for speech to text" ) - elif stage == PipelineStage.INTENT: + elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: raise PipelineRunValidationError( "intent_input is required for intent recognition" ) - elif stage == PipelineStage.TTS: + elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( "tts_input is required for text to speech" ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 28cafb7a355630..aa295ad5c626a3 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -5,13 +5,13 @@ import logging from typing import Any +import async_timeout import voluptuous as vol from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback from .pipeline import ( - DEFAULT_TIMEOUT, PipelineError, PipelineEvent, PipelineEventType, @@ -21,6 +21,8 @@ async_get_pipeline, ) +DEFAULT_TIMEOUT = 30 + _LOGGER = logging.getLogger(__name__) _VAD_ENERGY_THRESHOLD = 1000 @@ -155,37 +157,40 @@ def handle_binary(_hass, _connection, data: bytes): # Input to text to speech system input_args["tts_input"] = msg["input"]["text"] - run_task = hass.async_create_task( - PipelineInput(**input_args).execute( - PipelineRun( - hass, - context=connection.context(msg), - pipeline=pipeline, - start_stage=start_stage, - end_stage=end_stage, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - runner_data={ - "stt_binary_handler_id": handler_id, - }, - ), - timeout=timeout, - ) + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event.as_dict()), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, ) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return # Confirm subscription connection.send_result(msg["id"]) + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + try: # Task contains a timeout - await run_task - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) + async with async_timeout.timeout(timeout): + await run_task except asyncio.TimeoutError: connection.send_event( msg["id"], diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index c18af44b21cf75..a5812d170f6f4e 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -5,12 +5,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_audio_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -30,7 +31,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'test transcript', }) # --- @@ -58,7 +59,7 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': "Sorry, I couldn't understand that", }) # --- @@ -66,7 +67,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_-_test.mp3', }), }) # --- @@ -76,12 +77,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_intent_failed.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -91,12 +93,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 0.1, }), }) # --- # name: test_intent_timeout.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -112,6 +115,7 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- @@ -134,12 +138,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_stt_stream_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -156,12 +161,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_text_only_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -199,12 +205,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_tts_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': 'Lights are on.', }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 149d896dcf6b85..ce876550327555 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -93,7 +93,7 @@ def default_language(self) -> str: @property def supported_languages(self) -> list[str]: """Return list of supported languages.""" - return ["en"] + return ["en-US"] @property def supported_options(self) -> list[str]: @@ -264,7 +264,7 @@ async def sleepy_converse(*args, **kwargs): "start_stage": "intent", "end_stage": "intent", "input": {"text": "Are the lights on?"}, - "timeout": 0.00001, + "timeout": 0.1, } ) @@ -301,7 +301,7 @@ async def sleepy_run(*args, **kwargs): await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -381,7 +381,7 @@ async def sleepy_run(*args, **kwargs): await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -427,25 +427,8 @@ async def test_stt_provider_missing( # result msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - # stt error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "stt-provider-missing" + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-missing" async def test_stt_stream_failed( From 3f398818c56c982e3e1ec060e033c10b91c5d79c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 067/858] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e19ec..966f35fe98bbbe 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9afcc1..5ec555d88dfc93 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From 6db96847d557896ec34ba47eebcc0524daa8528e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 068/858] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577bf2a..d41ee0272a93d7 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cf5ca371c7542c..935d7bdb69f132 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2725,7 +2725,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edb2c970bcd068..27d71f3d569e9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1959,7 +1959,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From f4c341253b98df575e00e9f10e5868af1bd833af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 069/858] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b87a83..8e5951488ba058 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634effba5..a482e1b63b5dd6 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1bfd6b..4b3b9488bd8408 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From 3e59687902ad3a1819d43ec3cea54905094bc941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 070/858] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927bd01..e6ff9888b15981 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ async def force_data_refresh(self) -> None: self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 3e94f2a5029ae9d675ba9832a3e0e5cfe3b753fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 071/858] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd418719450..9da6f84207a257 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ async def _collection_changed(change_sets: Iterable[CollectionChangeSet]) -> Non # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 44b35fea47107d45b2b18d0a6453d4936ac4bfc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:18:29 -1000 Subject: [PATCH 072/858] Speed up entity filter when there are many glob matchers (#90615) * Speed up entity filter when there are many glob matchers Since we do no care about which glob matches we can combine all the translated globs into a single regex which reduces the overhead * delete unused code * preen --- homeassistant/helpers/entityfilter.py | 59 ++++++++++++--------------- tests/helpers/test_entityfilter.py | 2 +- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24ffc7..057e8f0955e377 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ def __init__(self, config: dict[str, list[str]]) -> None: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) + self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) self._filter: Callable[[str], bool] | None = None def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ def convert_include_exclude_filter( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: + """Convert a list of globs to a re pattern list.""" + if globs is None: + return None -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + if not translated_patterns: + return None -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: - """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def entity_included(domain: str, entity_id: str) -> bool: return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def entity_excluded(domain: str, entity_id: str) -> bool: return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def entity_filter_4a(entity_id: str) -> bool: return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def entity_filter_4a(entity_id: str) -> bool: def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9888704702cc0c..2141c2869142a0 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -369,7 +369,7 @@ def test_filter_schema_include_exclude() -> None: assert not filt.empty_filter -def test_exlictly_included() -> None: +def test_explicitly_included() -> None: """Test if an entity is explicitly included.""" conf = { "include": { From 90d81e9844747fd5ea75e894e7c7b998fb54532a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 31 Mar 2023 22:55:07 -0500 Subject: [PATCH 073/858] Use webrcvad to detect silence in pipelines (#90610) * Add webrtcvad requirement * Use webrcvad for voice command segmenting * Add vad test --- .../components/voice_assistant/manifest.json | 3 +- .../components/voice_assistant/vad.py | 128 ++++++++++++++++++ .../voice_assistant/websocket_api.py | 40 +----- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/voice_assistant/test_vad.py | 38 ++++++ .../voice_assistant/test_websocket.py | 2 +- 7 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/voice_assistant/vad.py create mode 100644 tests/components/voice_assistant/test_vad.py diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 644c49e945976b..f4a17bf52e70d3 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] } diff --git a/homeassistant/components/voice_assistant/vad.py b/homeassistant/components/voice_assistant/vad.py new file mode 100644 index 00000000000000..e86579b975073f --- /dev/null +++ b/homeassistant/components/voice_assistant/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + _in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self): + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self): + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self._in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self._in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self._in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index aa295ad5c626a3..718989f6613571 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -20,15 +20,12 @@ PipelineStage, async_get_pipeline, ) +from .vad import VoiceCommandSegmenter DEFAULT_TIMEOUT = 30 _LOGGER = logging.getLogger(__name__) -_VAD_ENERGY_THRESHOLD = 1000 -_VAD_SPEECH_FRAMES = 25 -_VAD_SILENCE_FRAMES = 25 - @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -36,17 +33,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) -def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: - """Compute RMS of debiased audio.""" - energy = -audioop.rms(audio_data, width) - energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) - debiased_energy = audioop.rms( - audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width - ) - - return debiased_energy - - @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -105,30 +91,14 @@ async def websocket_run( async def stt_stream(): state = None - speech_count = 0 - in_voice_command = False + segmenter = VoiceCommandSegmenter() # Yield until we receive an empty chunk while chunk := await audio_queue.get(): chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) - is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD - - if in_voice_command: - if is_speech: - speech_count += 1 - else: - speech_count -= 1 - - if speech_count <= -_VAD_SILENCE_FRAMES: - _LOGGER.info("Voice command stopped") - break - else: - if is_speech: - speech_count += 1 - - if speech_count >= _VAD_SPEECH_FRAMES: - in_voice_command = True - _LOGGER.info("Voice command started") + if not segmenter.process(chunk): + # Voice command is finished + break yield chunk diff --git a/requirements_all.txt b/requirements_all.txt index 935d7bdb69f132..e01e1c23e89b4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2619,6 +2619,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27d71f3d569e9c..89b84246ffdc70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1877,6 +1877,9 @@ wallbox==0.4.12 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/tests/components/voice_assistant/test_vad.py b/tests/components/voice_assistant/test_vad.py new file mode 100644 index 00000000000000..4285f78d51bbec --- /dev/null +++ b/tests/components/voice_assistant/test_vad.py @@ -0,0 +1,38 @@ +"""Tests for webrtcvad voice command segmenter.""" +from unittest.mock import patch + +from homeassistant.components.voice_assistant.vad import VoiceCommandSegmenter + +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +def test_silence() -> None: + """Test that 3 seconds of silence does not trigger a voice command.""" + segmenter = VoiceCommandSegmenter() + + # True return value indicates voice command has not finished + assert segmenter.process(bytes(_ONE_SECOND * 3)) + + +def test_speech() -> None: + """Test that silence + speech + silence triggers a voice command.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + + # silence + assert segmenter.process(bytes(_ONE_SECOND)) + + # "speech" + assert segmenter.process(bytes([255] * _ONE_SECOND)) + + # silence + # False return value indicates voice command is finished + assert not segmenter.process(bytes(_ONE_SECOND)) diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index ce876550327555..54fe51a7a22eec 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -75,7 +75,7 @@ async def async_get_engine( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: + ) -> stt.Provider: """Set up a mock speech component.""" return MockSttProvider(hass, _TRANSCRIPT) From e94c11371d49d61615ece0f4441d75f9dec9d1bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:22:26 -1000 Subject: [PATCH 074/858] Bump securetar to 2023.3.0 (#90612) changelog: https://github.com/pvizeli/securetar/compare/2022.02.0...2023.3.0 --- homeassistant/components/backup/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5cf79..fb7e9eff7806dc 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e01e1c23e89b4e..b91775525dfbcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ screenlogicpy==0.8.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89b84246ffdc70..709155b3ee1b64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1645,7 +1645,7 @@ scapy==2.5.0 screenlogicpy==0.8.2 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From f1fa63281e850e75041754f016a0612b557eda92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:24:02 -1000 Subject: [PATCH 075/858] Adjust context id variable names in the logbook processor to improve readability (#90617) Adjust some variable names in the logbook process to improve readablity There were some places were we used context_id that should have been context_id_bin --- homeassistant/components/logbook/processor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e98358a4c..7d0eec5eb6d9d3 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -204,7 +204,7 @@ def _humanify( # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin = context_lookup.memorize(row) if row.context_only: continue event_type = row.event_type @@ -232,7 +232,7 @@ def _humanify( if icon := row.icon or row.old_format_icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: @@ -240,7 +240,7 @@ def _humanify( data = describe_event(event_cache.get(row)) data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,7 +259,7 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data @@ -302,11 +302,11 @@ def __init__(self, logbook_run: LogbookRun) -> None: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin: + return self.context_lookup.get(context_id_bin) if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +314,13 @@ def _get_context_row( return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): From 9cab05c4b91262c34e4d3317ee1ce23aef73adb6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 076/858] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f918aaf..9e016db0376a05 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From b47ac524eadfed9c6be988c48792098b99677b86 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Apr 2023 17:47:31 +0200 Subject: [PATCH 077/858] Use async_timeout instead of asyncio.wait_for (#90496) * Use async_timeout instead of asyncio.wait_for * fix imports * fix imports * break out Event.wait patch * Update tests/components/reolink/conftest.py Co-authored-by: Martin Hjelmare * Simplify --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/reolink/host.py | 8 +++++-- tests/components/reolink/conftest.py | 9 ++++++-- tests/components/reolink/test_config_flow.py | 4 +++- tests/components/reolink/test_init.py | 22 ++++++++++++-------- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f7810746481bd5..e6c90343229859 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ import aiohttp from aiohttp.web import Request +import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -23,6 +24,7 @@ from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -146,11 +148,13 @@ async def async_init(self) -> None: "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) try: - await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): + await self._webhook_reachable.wait() except asyncio.TimeoutError: _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, + FIRST_ONVIF_TIMEOUT, ) ir.async_create_issue( self._hass, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index be748ef2c40074..d36aea905f74d9 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,8 +39,6 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, - ), patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: @@ -65,6 +63,13 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None yield host_mock +@pytest.fixture +def reolink_ONVIF_wait() -> Generator[None, None, None]: + """Mock reolink connection.""" + with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): + yield + + @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b3abb793a9f7a8..7d25fd62811afc 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -28,7 +28,9 @@ from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures( + "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" +) async def test_config_flow_manual_success(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 57d0dbd7cb7224..8dd6db270fb4b8 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,5 +1,4 @@ """Test the Reolink init.""" -import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -55,6 +54,7 @@ async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -71,7 +71,10 @@ async def test_failures_parametrized( async def test_entry_reloading( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -88,7 +91,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -106,7 +109,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -125,6 +128,7 @@ async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -144,10 +148,7 @@ async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - with patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", - AsyncMock(side_effect=asyncio.TimeoutError()), - ): + with patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -156,7 +157,10 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True From 00a4279d64ffb3dcd6e10a20d64b31788d3d7694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:14:59 -1000 Subject: [PATCH 078/858] Speed up backups (#90613) --- homeassistant/components/backup/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd556b8..f48a71a78c398f 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,6 +23,8 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB + @dataclass class Backup: @@ -99,7 +101,7 @@ def _read_backups(self) -> dict[str, Backup]: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ def _mkdir_and_generate_backup_contents( self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ def _mkdir_and_generate_backup_contents( with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, From 8263c3de2358548a14b75c714aa7db22d35fbb1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 079/858] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46b7b4..36c2fcc1279144 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ab00d9ca8181fc..1a5bc9407a8727 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index b91775525dfbcb..a7617899c41205 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 709155b3ee1b64..be8342a51da080 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9965d9d81db8f83c43c3a6f965c74ba45043b18b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 080/858] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497d396..41fd353359e31c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd75d36..a0ac73953b4bb5 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2852fe6786f70c204c6b3f191682913912bc7506 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 081/858] Update frontend to 20230401.0 (#90646) --- 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 114760923eb81c..6468bd6daa6834 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1a5bc9407a8727..8c49308503892b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index a7617899c41205..fedca635299f17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be8342a51da080..d637999fb3885c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 5fc103947f73e5f5dd733f2722704c61051caa9d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 03:39:46 +0200 Subject: [PATCH 082/858] Add entity name translations to Brother (#90634) * Add entity name translations * Fix sensor name * Update tests * Suggested change --- homeassistant/components/brother/sensor.py | 68 +++++------ homeassistant/components/brother/strings.json | 106 ++++++++++++++++++ tests/components/brother/test_sensor.py | 42 +++---- 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f31db9..191bfff249c870 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ class BrotherSensorEntityDescription( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abefa0cf..3ee3fe7609ff60 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 6769d219403129..e05fce9df3c37c 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensors(hass: HomeAssistant) -> None: SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", - suggested_object_id="hl_l2340dw_uptime", + suggested_object_id="hl_l2340dw_last_restart", disabled_by=None, ) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) @@ -132,14 +132,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -165,14 +165,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_black_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -198,14 +198,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -231,14 +231,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -264,14 +264,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -319,40 +319,40 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_page_counter" - state = hass.states.get("sensor.hl_l2340dw_duplex_unit_pages_counter") + state = hass.states.get("sensor.hl_l2340dw_duplex_unit_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter") + entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" - state = hass.states.get("sensor.hl_l2340dw_b_w_counter") + state = hass.states.get("sensor.hl_l2340dw_b_w_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" - state = hass.states.get("sensor.hl_l2340dw_color_counter") + state = hass.states.get("sensor.hl_l2340dw_color_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_counter") + entry = registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -360,7 +360,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" @@ -370,10 +370,10 @@ async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled From 84292d4797367e1a153c90874386eefed67f035a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 15:40:14 -1000 Subject: [PATCH 083/858] Cleanup some duplicate code in recorder statistics (#90549) * Cleanup some duplicate code in recorder statistics * more cleanup * reduce * reduce --- .../components/recorder/statistics.py | 106 +++++++++--------- tests/components/recorder/test_statistics.py | 26 ++--- 2 files changed, 60 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0122ba4464b88d..70e82fad5d7c02 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,18 +1034,19 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) if end_time is not None: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) @@ -1491,6 +1492,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1525,21 +1553,8 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table + start_time, end_time, metadata_ids, table, types ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1771,34 +1786,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" - return lambda_stmt( - lambda: columns.join( - ( - most_recent_statistic_ids := ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), - ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1809,23 +1824,8 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ff429794315b42..25890fe475b278 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1244,28 +1244,21 @@ def test_monthly_statistics( def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, - ) stmt3 = _generate_statistics_during_period_stmt( - columns2, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, + {"sum", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 @@ -1321,18 +1314,13 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) - stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, + stmt3 = _generate_statistics_at_time_stmt( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) - stmt3 = _generate_statistics_at_time_stmt(columns2, StatisticsShortTerm, {0}, 0.0) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 From 4a4d3201f5dbcbd1911b2929ef140e40ec62bc2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 23:34:52 -0400 Subject: [PATCH 084/858] Fix voice assistant error variable (#90658) --- homeassistant/components/voice_assistant/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index ef13d54e6a1781..b41ab8ef9f74dc 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -197,7 +197,7 @@ async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: raise SpeechToTextError( code="stt-provider-unsupported-metadata", message=( - f"Provider {engine} does not support input speech " + f"Provider {stt_provider.name} does not support input speech " "to text metadata" ), ) From 17270979e6d3627aaf2edbecdea28ad827006c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 08:09:44 -1000 Subject: [PATCH 085/858] Bump zeroconf to 0.52.0 (#90660) * Bump zeroconf to 0.52.0 Switch to using the new ip_addresses_by_version which avoids all the ip address conversions * updates --- homeassistant/components/zeroconf/__init__.py | 37 +++++-------------- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc1242714b35..a3a055b29c7ede 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -564,14 +564,19 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +585,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 36c2fcc1279144..09fc07684c5485 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.51.0"] + "requirements": ["zeroconf==0.52.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c49308503892b..704ebd99653c79 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.51.0 +zeroconf==0.52.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index fedca635299f17..885ccd97b3f59d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d637999fb3885c..7f28d06d5515cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From b52fab0f6d40296f3e5f38565cbebe451ec75a3b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 2 Apr 2023 11:22:16 -0700 Subject: [PATCH 086/858] Rename Android TV to Android Debug Bridge (#90657) * Rename Android TV to Android debug bridge * More renaming --- .../components/androidtv/__init__.py | 10 +- .../components/androidtv/config_flow.py | 11 ++- homeassistant/components/androidtv/const.py | 2 +- .../components/androidtv/manifest.json | 2 +- .../components/androidtv/media_player.py | 18 ++-- .../components/androidtv/services.yaml | 12 +-- .../components/androidtv/strings.json | 4 +- homeassistant/generated/integrations.json | 2 +- .../components/androidtv/test_config_flow.py | 6 +- .../components/androidtv/test_media_player.py | 92 +++++++++---------- 10 files changed, 80 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da6e65..4a1ad55e0b177f 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6cae3..7e2b1e85f39911 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ def _show_setup_form( async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ async def _async_check_connection( dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e1288519bfd..17936421680b73 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad3155..f782db79879ca0 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77ff99..563b8f07b2a09a 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ async def _adb_exception_catcher( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ def __init__( entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ async def async_select_source(self, source: str) -> None: @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ async def learn_sendevent(self): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ async def service_download(self, device_path, local_path): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ async def service_upload(self, device_path, local_path): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e52f82..4482f50f3e2672 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4e6b8..e7d06a9f6248b2 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9e5155f0cb8edc..311f8414f9ae5d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,7 +241,7 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index ed118bc8274b2d..ad7d3be290d570 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -48,14 +48,14 @@ HOST = "127.0.0.1" VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}] -# Android TV device with Python ADB implementation +# Android device with Python ADB implementation CONFIG_PYTHON_ADB = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, } -# Android TV device with ADB server +# Android device with ADB server CONFIG_ADB_SERVER = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, @@ -70,7 +70,7 @@ class MockConfigDevice: - """Mock class to emulate Android TV device.""" + """Mock class to emulate Android device.""" def __init__(self, eth_mac=ETH_MAC, wifi_mac=None): """Initialize a fake device to test config flow.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 3ecbd5b05f4806..59c7ce751ace37 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -95,8 +95,8 @@ SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_STANDBY = "1" -# Android TV device with Python ADB implementation -CONFIG_ANDROIDTV_PYTHON_ADB = { +# Android device with Python ADB implementation +CONFIG_ANDROID_PYTHON_ADB = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -106,28 +106,28 @@ }, } -# Android TV device with Python ADB implementation imported from YAML -CONFIG_ANDROIDTV_PYTHON_ADB_YAML = { +# Android device with Python ADB implementation imported from YAML +CONFIG_ANDROID_PYTHON_ADB_YAML = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: "ADB yaml import", DOMAIN: { CONF_NAME: "ADB yaml import", - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], }, } -# Android TV device with Python ADB implementation with custom adbkey -CONFIG_ANDROIDTV_PYTHON_ADB_KEY = { +# Android device with Python ADB implementation with custom adbkey +CONFIG_ANDROID_PYTHON_ADB_KEY = { ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: CONFIG_ANDROIDTV_PYTHON_ADB[TEST_ENTITY_NAME], + TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], DOMAIN: { - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], CONF_ADBKEY: "user_provided_adbkey", }, } -# Android TV device with ADB server -CONFIG_ANDROIDTV_ADB_SERVER = { +# Android device with ADB server +CONFIG_ANDROID_ADB_SERVER = { ADB_PATCH_KEY: patchers.KEY_SERVER, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -163,7 +163,7 @@ }, } -CONFIG_ANDROIDTV_DEFAULT = CONFIG_ANDROIDTV_PYTHON_ADB +CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB @@ -213,10 +213,10 @@ def _setup(config): @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, - CONFIG_ANDROIDTV_PYTHON_ADB_YAML, + CONFIG_ANDROID_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB_YAML, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -275,9 +275,9 @@ async def test_reconnect( @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -313,7 +313,7 @@ async def test_adb_shell_returns_none( async def test_setup_with_adbkey(hass: HomeAssistant) -> None: """Test that setup succeeds when using an ADB key.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB_KEY) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_PYTHON_ADB_KEY) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -331,12 +331,12 @@ async def test_setup_with_adbkey(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_DEFAULT, + CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT, ], ) async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" + """Test that sources (i.e., apps) are handled correctly for Android and Fire TV devices.""" conf_apps = { "com.app.test1": "TEST 1", "com.app.test3": None, @@ -397,7 +397,7 @@ async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.parametrize( ("config", "expected_sources"), [ - (CONFIG_ANDROIDTV_DEFAULT, ["TEST 1"]), + (CONFIG_ANDROID_DEFAULT, ["TEST 1"]), (CONFIG_FIRETV_DEFAULT, ["TEST 1"]), ], ) @@ -503,7 +503,7 @@ async def test_select_source_androidtv( "com.app.test3": None, } await _test_select_source( - hass, CONFIG_ANDROIDTV_DEFAULT, conf_apps, source, expected_arg, method_patch + hass, CONFIG_ANDROID_DEFAULT, conf_apps, source, expected_arg, method_patch ) @@ -517,7 +517,7 @@ async def test_androidtv_select_source_overridden_app_name(hass: HomeAssistant) assert "com.youtube.test" not in ANDROIDTV_APPS await _test_select_source( hass, - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, conf_apps, "YouTube", "com.youtube.test", @@ -554,9 +554,9 @@ async def test_select_source_firetv( @pytest.mark.parametrize( ("config", "connect"), [ - (CONFIG_ANDROIDTV_DEFAULT, False), + (CONFIG_ANDROID_DEFAULT, False), (CONFIG_FIRETV_DEFAULT, False), - (CONFIG_ANDROIDTV_DEFAULT, True), + (CONFIG_ANDROID_DEFAULT, True), (CONFIG_FIRETV_DEFAULT, True), ], ) @@ -581,7 +581,7 @@ async def test_setup_fail( async def test_adb_command(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = "test response" @@ -610,7 +610,7 @@ async def test_adb_command(hass: HomeAssistant) -> None: async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = b"test response" @@ -639,7 +639,7 @@ async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: async def test_adb_command_key(hass: HomeAssistant) -> None: """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "HOME" response = None @@ -668,7 +668,7 @@ async def test_adb_command_key(hass: HomeAssistant) -> None: async def test_adb_command_get_properties(hass: HomeAssistant) -> None: """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} @@ -698,7 +698,7 @@ async def test_adb_command_get_properties(hass: HomeAssistant) -> None: async def test_learn_sendevent(hass: HomeAssistant) -> None: """Test the `androidtv.learn_sendevent` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" @@ -727,7 +727,7 @@ async def test_learn_sendevent(hass: HomeAssistant) -> None: async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -760,7 +760,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: async def test_download(hass: HomeAssistant) -> None: """Test the `androidtv.download` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -806,7 +806,7 @@ async def test_download(hass: HomeAssistant) -> None: async def test_upload(hass: HomeAssistant) -> None: """Test the `androidtv.upload` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -851,8 +851,8 @@ async def test_upload(hass: HomeAssistant) -> None: async def test_androidtv_volume_set(hass: HomeAssistant) -> None: - """Test setting the volume for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test setting the volume for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -881,7 +881,7 @@ async def test_get_image_http( This is based on `test_get_image_http` in tests/components/media_player/test_init.py. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -894,7 +894,7 @@ async def test_get_image_http( await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -923,7 +923,7 @@ async def test_get_image_http( async def test_get_image_disabled(hass: HomeAssistant) -> None: """Test that the screencap option can disable entity_picture.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, options={CONF_SCREENCAP: False} @@ -939,7 +939,7 @@ async def test_get_image_disabled(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -954,7 +954,7 @@ async def _test_service( additional_service_data=None, return_value=None, ): - """Test generic Android TV media player entity service.""" + """Test generic Android media player entity service.""" service_data = {ATTR_ENTITY_ID: entity_id} if additional_service_data: service_data.update(additional_service_data) @@ -977,8 +977,8 @@ async def _test_service( async def test_services_androidtv(hass: HomeAssistant) -> None: - """Test media player services for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test media player services for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1042,7 +1042,7 @@ async def test_services_firetv(hass: HomeAssistant) -> None: async def test_volume_mute(hass: HomeAssistant) -> None: """Test the volume mute service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1085,7 +1085,7 @@ async def test_volume_mute(hass: HomeAssistant) -> None: async def test_connection_closed_on_ha_stop(hass: HomeAssistant) -> None: """Test that the ADB socket connection is closed when HA stops.""" - patch_key, _, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, _, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1105,7 +1105,7 @@ async def test_exception(hass: HomeAssistant) -> None: HA will attempt to reconnect on the next update. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1135,7 +1135,7 @@ async def test_exception(hass: HomeAssistant) -> None: async def test_options_reload(hass: HomeAssistant) -> None: """Test changing an option that will cause integration reload.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( From d32fb7c22f645b0abca92e0d5dbda427828ac8fc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:24:40 +0200 Subject: [PATCH 087/858] Add entity name translations to Airly (#90656) Add entity name translations --- homeassistant/components/airly/sensor.py | 22 ++++++------ homeassistant/components/airly/strings.json | 37 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8b070..53e15c651a7b73 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="Ozone", + translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4f95f26afc098e..93fcffa571eb8d 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "CAQI" + }, + "pm1": { + "name": "PM1.0" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "humidity": { + "name": "Humidity" + }, + "pressure": { + "name": "Pressure" + }, + "temperature": { + "name": "Temperature" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, + "so2": { + "name": "Sulphur dioxide" + }, + "o3": { + "name": "Ozone" + } + } } } From fc81b829326281ac53d92025ce754cec651f40a1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:25:38 +0200 Subject: [PATCH 088/858] Add entity name translations to GIOS (#90655) * Add entity name translations * Update tests --- homeassistant/components/gios/sensor.py | 20 ++++-------- homeassistant/components/gios/strings.json | 27 +++++++++++++++ tests/components/gios/test_sensor.py | 38 +++++++++++----------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7cf4b7e7c600d7..f078cc074e9c4c 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,7 +60,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, @@ -69,35 +68,34 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", value=lambda sensors: sensors.no2.value if sensors.no2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, subkey="index", - name="NO2 index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -106,17 +104,16 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", value=lambda sensors: sensors.o3.value if sensors.o3 else None, suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, subkey="index", - name="O3 index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -125,17 +122,16 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, subkey="index", - name="PM10 index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -144,17 +140,16 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, subkey="index", - name="PM2.5 index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -163,17 +158,16 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", value=lambda sensors: sensors.so2.value if sensors.so2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, subkey="index", - name="SO2 index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 53e7dd78a8f9ac..bbbd1c3e6ccba5 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "AQI", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -35,7 +36,17 @@ "very_good": "Very good" } }, + "c6h6": { + "name": "Benzene" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, "no2_index": { + "name": "Nitrogen dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -45,7 +56,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "o3": { + "name": "Ozone" + }, "o3_index": { + "name": "Ozone index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -55,7 +70,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm10": { + "name": "PM10" + }, "pm10_index": { + "name": "PM10 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -65,7 +84,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm25": { + "name": "PM2.5" + }, "pm25_index": { + "name": "PM2.5 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -75,7 +98,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "so2": { + "name": "Sulphur dioxide" + }, "so2_index": { + "name": "Sulphur dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 48f0e2384011df..2eb74ec1219658 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensor(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_benzene") assert state assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -46,11 +46,11 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_c6h6") + entry = registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_carbon_monoxide") assert state assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -61,11 +61,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_co") + entry = registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_nitrogen_dioxide") assert state assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -76,11 +76,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_no2") + entry = registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -94,11 +94,11 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_no2_index") + entry = registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_ozone") assert state assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -109,11 +109,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_o3") + entry = registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -127,7 +127,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_o3_index") + entry = registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -197,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "123-pm25-index" - state = hass.states.get("sensor.home_so2") + state = hass.states.get("sensor.home_sulphur_dioxide") assert state assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -208,11 +208,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_so2") + entry = registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == "very_good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -226,7 +226,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_so2_index") + entry = registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -341,11 +341,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == STATE_UNAVAILABLE @@ -357,7 +357,7 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE From c5a87addc165f234788f598fb5b0bb99efe24088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Apr 2023 14:28:52 -0400 Subject: [PATCH 089/858] Fix frontend test (#90679) --- tests/components/frontend/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 69643b10ec2f6f..dcff80d3594720 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -141,7 +141,7 @@ async def test_frontend_and_static(mock_http_client, mock_onboarded) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9]{8}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_]{11}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -546,7 +546,7 @@ async def test_auth_authorize(mock_http_client) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_]{11}.js)", text ) assert authorizejs is not None, text From 368d1c9b54878c20014eeac2aacc575b2bc74333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 13:32:00 -1000 Subject: [PATCH 090/858] Bump zeroconf to 0.53.0 (#90682) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 09fc07684c5485..551471b41e089c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.52.0"] + "requirements": ["zeroconf==0.53.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 704ebd99653c79..c8cd41b30793b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.52.0 +zeroconf==0.53.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 885ccd97b3f59d..362797c37f6a73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f28d06d5515cf..1fd34a61a579de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 22fd6138bd9f11e19dd657126f9d239193ecc703 Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Mon, 3 Apr 2023 02:19:03 +0200 Subject: [PATCH 091/858] Add entity name translations for Nest sensors (#90677) Signed-off-by: Patrick ZAJDA --- homeassistant/components/nest/sensor_sdm.py | 4 ++-- homeassistant/components/nest/strings.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c2a36..8eb607b2056650 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index bf68d1988d63e6..c0c7042423bc1a 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } From 6a6b6cf826939c699dc1de1b3215519941fb231f Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sun, 2 Apr 2023 20:20:11 -0400 Subject: [PATCH 092/858] Bump env_canada to v0.5.30 (#90644) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index c2c2485d94806d..8e1f17492fba47 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.29"] + "requirements": ["env_canada==0.5.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 362797c37f6a73..b4def3e903dc50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fd34a61a579de..1c292cc65bf0c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,7 +517,7 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 0198c751b450bf3a71654e2bc63a6f4bb6e6e768 Mon Sep 17 00:00:00 2001 From: mletenay Date: Mon, 3 Apr 2023 02:25:29 +0200 Subject: [PATCH 093/858] Update goodwe library to v0.2.30 (#90607) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 8dad8454d6b9b8..45d02dcd2e3df4 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.29"] + "requirements": ["goodwe==0.2.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4def3e903dc50..4b60930b9e280e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c292cc65bf0c5..4d983d61f55d7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 From 17719663f090263920403017208de3c9b14f3c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 14:51:25 -1000 Subject: [PATCH 094/858] Fix memory churn in state templates (#90685) * Fix memory churn in state templates The LRU for state templates was limited to 512 states. As soon as it was exaused, system performance would tank as each template that iterated all states would have to create and GC any state > 512 * does it scale? * avoid copy on all * comment * preen * cover * cover * comments * comments * comments * preen * preen --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/template.py | 98 +++++++++++++++++++++++++++---- tests/helpers/test_template.py | 40 ++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 445ff35793c907..d98680c70d4cfb 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,6 +239,7 @@ def _cache_uname_processor() -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e5951488ba058..fb693d6957d149 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -41,6 +41,7 @@ from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.const import ( @@ -49,6 +50,8 @@ ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, @@ -121,11 +124,77 @@ "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -969,21 +1038,33 @@ def __repr__(self) -> str: return f"